Widgets/notifications: tracked-event auto-advance, lock-screen dots, Snooze labels, period milestones
- Bars widget: tracked ("Track Countdown") events now also advance — a completed
or past (non-recurring) tracked event falls through to the next-nearest, and the
refresh boundary covers the tracked event's expiry. Recurring tracked events
still roll forward. Completes the KC-26 auto-advance for all paths.
- Progress Dots on the lock screen (accessoryRectangular): show 28 larger dots
instead of 100 tiny ones, render with .primary for the vibrant tint, and use
AccessoryWidgetBackground so it's legible (Home Screen keeps the 100-dot grid).
- Notifications: snooze action labels now read "Snooze 15 min/30 min/1 hour/
2 hours" (lock screen) and "Snooze 15 Minutes…" (in-app Postpone menu).
- New period milestone notifications: recurring "One more week/month/year passed"
at end of week (Sun 20:00), month (1st 09:00), and year (Jan 1 09:00); gated by
a kisani.periodMilestones flag, scheduled via the existing reschedule path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -238,11 +238,26 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||
}
|
||||
|
||||
/// Due date of the event the auto-select queue is currently showing (nil if not in
|
||||
/// queue mode, an event is tracked, or there's nothing upcoming).
|
||||
/// The tracked task ("Track Countdown"), but only while it's still live: not
|
||||
/// completed, and either recurring or with a deadline still ahead of `now`.
|
||||
/// Returns nil once it's done/expired so the widget advances to the next event.
|
||||
private func trackedTask(asOf now: Date) -> WidgetTask? {
|
||||
guard let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||
let due = task.dueDate,
|
||||
!task.isComplete,
|
||||
task.isRecurring || due > now
|
||||
else { return nil }
|
||||
return task
|
||||
}
|
||||
|
||||
/// Due date the currently-shown event expires at, so the timeline can refresh
|
||||
/// the moment it's done. Covers both the tracked event and the auto-select queue.
|
||||
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
|
||||
guard config.upcomingQueue,
|
||||
UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent") == nil else { return nil }
|
||||
if let task = trackedTask(asOf: now) {
|
||||
return task.isRecurring ? nil : task.dueDate
|
||||
}
|
||||
guard config.upcomingQueue else { return nil }
|
||||
let upcoming = upcomingEvents(asOf: now)
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
guard idx < upcoming.count else { return nil }
|
||||
@@ -266,14 +281,14 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
|
||||
// An event tracked from the app ("Track Countdown") bypasses the widget
|
||||
// configuration entirely — title, date, and progress come straight from
|
||||
// App Group storage.
|
||||
if let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||
let due = task.dueDate {
|
||||
// configuration entirely. Once it's completed or its deadline has passed
|
||||
// (and it isn't recurring), stop pinning it and fall through to the
|
||||
// next-nearest event below — so the widget always advances.
|
||||
if let task = trackedTask(asOf: now) {
|
||||
let trackedId = task.id.uuidString
|
||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
|
||||
anchorKey: trackedId, explicitStart: since)
|
||||
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
|
||||
label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since)
|
||||
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,25 +77,33 @@ struct ProgressDotView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isAccessory: Bool { family == .accessoryRectangular }
|
||||
|
||||
// 100 tiny dots are illegible in a lock-screen pill — show a coarse grid there.
|
||||
private var dotCount: Int { isAccessory ? 28 : entry.totalDots }
|
||||
|
||||
var body: some View {
|
||||
let percent = Int((entry.progress * 100).rounded(.down))
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(entry.title)
|
||||
.font(headerFont)
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(isAccessory ? .primary : .white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(headerFont)
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.foregroundColor(isAccessory ? .primary.opacity(0.7) : Color(white: 0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
DotGridView(progress: entry.progress, totalDots: entry.totalDots, color: accent)
|
||||
DotGridView(progress: entry.progress, totalDots: dotCount,
|
||||
color: isAccessory ? .primary : accent)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
.containerBackground(for: .widget) {
|
||||
if isAccessory { AccessoryWidgetBackground() } else { WidgetTheme.background }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user