From b6eaba78baf11340eceb4cf94ed9105e29229b00 Mon Sep 17 00:00:00 2001 From: kutesir Date: Thu, 11 Jun 2026 23:05:33 +0300 Subject: [PATCH] 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 --- KisaniCal/ISSUES.md | 38 ++++++++++++++++ KisaniCal/Views/TaskContextMenu.swift | 28 ++++++++++++ KisaniCalWidgets/EventCountdownWidget.swift | 50 ++++++++++++++++++++- KisaniCalWidgets/WidgetData.swift | 9 +++- 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 4a24fe7..f797282 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift index 3863f36..3b526bb 100644 --- a/KisaniCal/Views/TaskContextMenu.swift +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -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") } } diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index eab5df7..f6f3fce 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -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) diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift index befa114..90c0697 100644 --- a/KisaniCalWidgets/WidgetData.swift +++ b/KisaniCalWidgets/WidgetData.swift @@ -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 } }