Bars widget: app-driven "Track Countdown" with zero config (KC-20)

- Task long-press menu gains Track/Stop Tracking Countdown, storing the
  tracked event id + tracking date in the App Group (CountdownTracker)
  and reloading widget timelines.
- The widget reads the tracked event directly and bypasses its config:
  Mode A (one-off) = tracking date -> event date; Mode B (recurring) =
  previous occurrence -> next occurrence via the task's recurrence rule.
- WidgetTask decodes isRecurring/recurrenceLabel from the shared JSON.
- With nothing tracked, the configured modes remain the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-11 23:05:33 +03:00
parent a7639f6454
commit b6eaba78ba
4 changed files with 122 additions and 3 deletions

View File

@@ -229,6 +229,27 @@ struct EventProvider: AppIntentTimelineProvider {
}
private func entry(for config: EventConfigIntent) -> EventEntry {
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 {
// Mode B recurring (birthdays/anniversaries): previous next occurrence.
if task.isRecurring, let label = task.recurrenceLabel,
let span = Self.recurrenceSpan(label: label, due: due) {
return EventEntry(date: .now, eventName: task.title,
start: span.prev, target: span.next, unitMode: mode)
}
// Mode A one-off: tracking date event date.
let since = (UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date)
?? Self.storedAnchor(key: trackedId, target: due)
return EventEntry(date: .now, eventName: task.title,
start: min(since, due), target: due, unitMode: mode)
}
var name: String
var target: Date
var anchorKey: String
@@ -268,7 +289,6 @@ struct EventProvider: AppIntentTimelineProvider {
} else {
start = Self.storedAnchor(key: anchorKey, target: target)
}
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
}
@@ -282,6 +302,34 @@ struct EventProvider: AppIntentTimelineProvider {
ud.set(now, forKey: k)
return now
}
/// Mode B window for a recurring event: the occurrence span containing "now"
/// (previous occurrence next occurrence), derived from the rule label.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let now = Date()
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
// Walk back until the previous occurrence is in the past.
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
} else {
while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 { next = n; guardRail += 1 }
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
}
// MARK: - Event Countdown (Bars)

View File

@@ -10,6 +10,8 @@ struct WidgetTask: Codable, Identifiable {
let isComplete: Bool
let quadrant: String // raw value of Quadrant enum
let category: String
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
// Days remaining until due date
var daysLeft: Int? {
@@ -60,7 +62,8 @@ func loadWidgetTasks() -> [WidgetTask] {
else { return [] }
return raw.map {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category)
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel)
}
}
@@ -72,8 +75,10 @@ private struct RawTask: Codable {
var isComplete: Bool = false
var quadrant: String = "Urgent + Important"
var category: String = "reminder"
var isRecurring: Bool? = nil
var recurrenceLabel: String? = nil
enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
}
}