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:
@@ -606,3 +606,41 @@ All widgets should match the brand orange theme of the logo exactly.
|
||||
- Lock-screen ring tints → brand accent (renders in color on StandBy/home).
|
||||
|
||||
Files: `WidgetViews.swift`, `TaskLiveActivity.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-20 — "Track Countdown": zero-config bars widget driven from the app
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets / Tasks
|
||||
|
||||
### Description
|
||||
The bars widget should not need manual event entry: track any KisaniCal event
|
||||
from the app and the widget displays it automatically (title, date, time left,
|
||||
progress bars).
|
||||
|
||||
### Fix
|
||||
- **App:** new `CountdownTracker` + a "Track Countdown" / "Stop Tracking
|
||||
Countdown" item in the shared task long-press menu. Stores
|
||||
`kisani.widget.trackedEvent` (+ `trackedSince`) in the App Group and reloads
|
||||
widget timelines.
|
||||
- **Widget:** the tracked event bypasses the widget configuration entirely.
|
||||
- **Mode A** (one-off): progress = elapsed/(event − tracking date).
|
||||
- **Mode B** (recurring birthdays/anniversaries): progress across the
|
||||
occurrence span containing now (previous → next occurrence), derived from
|
||||
the task's recurrence rule (`recurrenceSpan`).
|
||||
- `WidgetTask` now decodes `isRecurring`/`recurrenceLabel` from the shared JSON.
|
||||
- No tracked event → existing configuration (upcoming queue / picked / custom)
|
||||
remains the fallback, exactly as before.
|
||||
- Bar rendering already maps progress → filled (accent) vs remaining (dimmed)
|
||||
bars; the screenshot showing static dim bars was the pre-KC-18 build.
|
||||
|
||||
Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift`.
|
||||
|
||||
### How to test (device)
|
||||
1. Long-press any task with a due date → "Track Countdown".
|
||||
2. The bars widget switches to that event without touching its config.
|
||||
3. A yearly birthday shows Mode B: bar spans last year's → this year's date.
|
||||
4. Long-press the task again → "Stop Tracking Countdown" → widget falls back
|
||||
to its configured mode.
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Tracks one event for the Event Countdown (Bars) widget via App Group storage.
|
||||
/// The widget reads these keys directly — no manual widget configuration needed.
|
||||
enum CountdownTracker {
|
||||
static let idKey = "kisani.widget.trackedEvent"
|
||||
static let sinceKey = "kisani.widget.trackedSince"
|
||||
|
||||
static func isTracked(_ task: TaskItem) -> Bool {
|
||||
UserDefaults.kisani.string(forKey: idKey) == task.id.uuidString
|
||||
}
|
||||
|
||||
static func toggle(_ task: TaskItem) {
|
||||
if isTracked(task) {
|
||||
UserDefaults.kisani.removeObject(forKey: idKey)
|
||||
UserDefaults.kisani.removeObject(forKey: sinceKey)
|
||||
} else {
|
||||
UserDefaults.kisani.set(task.id.uuidString, forKey: idKey)
|
||||
UserDefaults.kisani.set(Date(), forKey: sinceKey) // Mode A anchor
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified long-press menu for a task, shared across Today, Matrix, and Calendar.
|
||||
/// Pass only the closures a given screen needs; the rest default to no-ops.
|
||||
@@ -67,6 +90,11 @@ struct TaskMenuItems: View {
|
||||
Label("Add to Live Activity", systemImage: "pin.circle")
|
||||
}
|
||||
|
||||
Button { CountdownTracker.toggle(task) } label: {
|
||||
Label(CountdownTracker.isTracked(task) ? "Stop Tracking Countdown" : "Track Countdown",
|
||||
systemImage: "timer")
|
||||
}
|
||||
|
||||
if let onEdit {
|
||||
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user