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:
kutesir
2026-06-16 02:12:52 +03:00
parent 87c999c676
commit 48e2f4fc10
7 changed files with 122 additions and 52 deletions

View File

@@ -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)
}

View File

@@ -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 }
}
}
}