Bars widget: fix empty progress bar + dead-feeling date pickers (KC-18)
- Persist a per-event "first tracked" anchor in the App Group instead of
resetting the progress start to now on every refresh (auto mode), so
the bar actually fills toward the event. Explicit "Counting from" wins
when it's before the event date.
- Make Event date / Counting from optional ("Choose") — the same-second
defaults produced a zero-length span (always 0%) and looked broken.
- Pre-render timeline entries every 30 min (12h horizon) so the bar and
time-left label move between refreshes instead of only at midnight.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -550,3 +550,37 @@ to standard and removed entirely from the gallery.
|
|||||||
Files modified: `KisaniCalWidgets.swift`, `EventCountdownWidget.swift`,
|
Files modified: `KisaniCalWidgets.swift`, `EventCountdownWidget.swift`,
|
||||||
`WidgetViews.swift`, `WidgetData.swift`. No files deleted (shared code remains
|
`WidgetViews.swift`, `WidgetData.swift`. No files deleted (shared code remains
|
||||||
in EventCountdownWidget.swift). No app-side settings referenced these widgets.
|
in EventCountdownWidget.swift). No app-side settings referenced these widgets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-18 — Bars widget: progress bar never fills; date pickers felt broken
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Widgets
|
||||||
|
|
||||||
|
### Symptoms
|
||||||
|
The Event Countdown (Bars) widget showed all bars dim regardless of time left,
|
||||||
|
and the Event date / Counting from rows in the config appeared not to work.
|
||||||
|
|
||||||
|
### Root causes
|
||||||
|
1. Auto (upcoming-queue) mode anchored progress to `Date()` on **every** refresh,
|
||||||
|
so elapsed time was always ~0 → empty bar forever.
|
||||||
|
2. Custom mode defaulted both `Event date` and `Counting from` to the same
|
||||||
|
instant (`Date.now` at config time) → zero-length span → progress 0. The raw
|
||||||
|
second-precision defaults also made the picker rows look broken.
|
||||||
|
3. The timeline only refreshed at midnight, so even a correct bar wouldn't move
|
||||||
|
during an "X hr left" countdown.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Persistent per-event anchor (`kisani.widget.anchor.<id>` in the App Group):
|
||||||
|
progress fills from when an event was first tracked and resets naturally when
|
||||||
|
the tracked event changes. An explicit "Counting from" before the event wins.
|
||||||
|
- `targetDate`/`startDate` are now optional — rows read "Choose" until set, and a
|
||||||
|
missing custom date falls back to a 2-day stub window.
|
||||||
|
- Timeline pre-renders an entry every 30 minutes (12 h horizon) so the bar and
|
||||||
|
time-left label keep moving.
|
||||||
|
|
||||||
|
Files: `KisaniCalWidgets/EventCountdownWidget.swift`.
|
||||||
|
Note: existing widget instances re-read their config; dates must be re-picked
|
||||||
|
once (params changed to optional).
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ struct EventConfigIntent: WidgetConfigurationIntent {
|
|||||||
@Parameter(title: "Or event name", default: "My Event")
|
@Parameter(title: "Or event name", default: "My Event")
|
||||||
var eventName: String
|
var eventName: String
|
||||||
|
|
||||||
@Parameter(title: "Event date", default: Date.now)
|
// Optional so the rows read "Choose" until the user actually picks a date —
|
||||||
var targetDate: Date
|
// a same-second default for both made the bar span zero and looked broken.
|
||||||
|
@Parameter(title: "Event date")
|
||||||
|
var targetDate: Date?
|
||||||
|
|
||||||
@Parameter(title: "Counting from", default: Date.now)
|
@Parameter(title: "Counting from")
|
||||||
var startDate: Date
|
var startDate: Date?
|
||||||
|
|
||||||
// Show only the relevant fields depending on the toggle — like a clean,
|
// Show only the relevant fields depending on the toggle — like a clean,
|
||||||
// single-purpose config screen instead of every field at once.
|
// single-purpose config screen instead of every field at once.
|
||||||
@@ -204,10 +206,18 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
||||||
let entry = entry(for: configuration)
|
let base = entry(for: configuration)
|
||||||
// Recompute at the next midnight so the grid advances one dot per day.
|
// Live progress: pre-render an entry every 30 minutes for the next 12 hours
|
||||||
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
// so the bar and time-left label keep moving between refreshes.
|
||||||
return Timeline(entries: [entry], policy: .after(nextMidnight))
|
let entries: [EventEntry] = (0..<24).map { step in
|
||||||
|
EventEntry(date: Date().addingTimeInterval(Double(step) * 1800),
|
||||||
|
eventName: base.eventName,
|
||||||
|
start: base.start,
|
||||||
|
target: base.target,
|
||||||
|
unitMode: base.unitMode)
|
||||||
|
}
|
||||||
|
let next = Date().addingTimeInterval(24 * 1800)
|
||||||
|
return Timeline(entries: entries, policy: .after(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upcoming events (incomplete tasks with a future due date), nearest first.
|
/// Upcoming events (incomplete tasks with a future due date), nearest first.
|
||||||
@@ -221,6 +231,7 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
private func entry(for config: EventConfigIntent) -> EventEntry {
|
private func entry(for config: EventConfigIntent) -> EventEntry {
|
||||||
var name: String
|
var name: String
|
||||||
var target: Date
|
var target: Date
|
||||||
|
var anchorKey: String
|
||||||
|
|
||||||
if config.upcomingQueue {
|
if config.upcomingQueue {
|
||||||
// Auto-track the Nth nearest upcoming event.
|
// Auto-track the Nth nearest upcoming event.
|
||||||
@@ -229,27 +240,48 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
if idx < upcoming.count, let due = upcoming[idx].dueDate {
|
if idx < upcoming.count, let due = upcoming[idx].dueDate {
|
||||||
name = upcoming[idx].title
|
name = upcoming[idx].title
|
||||||
target = due
|
target = due
|
||||||
|
anchorKey = upcoming[idx].id
|
||||||
} else {
|
} else {
|
||||||
name = "No upcoming event"
|
name = "No upcoming event"
|
||||||
target = Date().addingTimeInterval(86400)
|
target = Date().addingTimeInterval(86400)
|
||||||
|
anchorKey = "none"
|
||||||
}
|
}
|
||||||
} else if let ev = config.event, let due = ev.dueDate {
|
} else if let ev = config.event, let due = ev.dueDate {
|
||||||
// A specifically picked event supplies its own name + date.
|
// A specifically picked event supplies its own name + date.
|
||||||
name = ev.title
|
name = ev.title
|
||||||
target = due
|
target = due
|
||||||
|
anchorKey = ev.id
|
||||||
} else {
|
} else {
|
||||||
// Fully custom event.
|
// Fully custom event. No date picked yet → count to tomorrow as a stub.
|
||||||
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||||
target = config.targetDate
|
target = config.targetDate
|
||||||
|
?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400)
|
||||||
|
anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counting anchor: custom mode honors the picker; auto mode counts from
|
// Counting anchor: an explicit "Counting from" wins when it's before the
|
||||||
// "now" so the grid fills toward the event. Never start after the target.
|
// event; otherwise remember when this event was first tracked so the bar
|
||||||
let anchor = config.upcomingQueue ? Date() : config.startDate
|
// fills steadily instead of resetting to "now" on every refresh.
|
||||||
let start = min(anchor, target)
|
let start: Date
|
||||||
|
if let s = config.startDate, s < target {
|
||||||
|
start = s
|
||||||
|
} else {
|
||||||
|
start = Self.storedAnchor(key: anchorKey, target: target)
|
||||||
|
}
|
||||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||||
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First-seen date for a tracked event, persisted in the App Group so progress
|
||||||
|
/// is stable across refreshes (and resets naturally when the event changes).
|
||||||
|
private static func storedAnchor(key: String, target: Date) -> Date {
|
||||||
|
let ud = UserDefaults.kisani
|
||||||
|
let k = "kisani.widget.anchor.\(key)"
|
||||||
|
if let saved = ud.object(forKey: k) as? Date, saved < target { return saved }
|
||||||
|
let now = Date()
|
||||||
|
ud.set(now, forKey: k)
|
||||||
|
return now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Event Countdown (Bars)
|
// MARK: - Event Countdown (Bars)
|
||||||
|
|||||||
Reference in New Issue
Block a user