feat: My Tasks widget, event countdown redesign, completed section

- My Tasks widget: today's tasks with interactive check-off (ToggleTaskIntent)
  and a "+" that opens the app's add-task sheet; writes preserve all task
  fields and sync via the App Group.
- Event Countdown: AZURE/AWS-style header (accent name + countdown label +
  percent); tap the label to cycle days → weeks → time remaining.
- Today: collapsible "Completed" archive section below Upcoming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-03 13:31:04 +03:00
parent 8b094691f5
commit 646cdf6a9b
10 changed files with 386 additions and 44 deletions

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import WidgetKit
// Minimal task model shared between widget and main app via App Group
struct WidgetTask: Codable, Identifiable {
@@ -84,6 +85,59 @@ func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
.first
}
private func activeTasksKey() -> String {
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
return "kisani.tasks.v2.\(uid)"
}
// Tasks due today (incomplete first, then by due time) for the interactive list widget.
func todayWidgetTasks() -> [WidgetTask] {
let cal = Calendar.current
let start = cal.startOfDay(for: Date())
let end = cal.date(byAdding: .day, value: 1, to: start)!
return loadWidgetTasks()
.filter { t in
guard let due = t.dueDate else { return false }
return due >= start && due < end
}
.sorted {
if $0.isComplete != $1.isComplete { return !$0.isComplete }
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
}
}
// Toggle a task's completion from the widget. Uses JSONSerialization so ALL task
// fields are preserved on write (the widget only models a subset of TaskItem).
func toggleWidgetTaskComplete(id: String) {
let key = activeTasksKey()
guard let data = UserDefaults.kisani.data(forKey: key),
var arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
else { return }
for i in arr.indices where (arr[i]["id"] as? String) == id {
let done = (arr[i]["isComplete"] as? Bool) ?? false
arr[i]["isComplete"] = !done
if !done {
// TaskItem uses the default (deferredToDate) Date strategy seconds since reference date.
arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate
} else {
arr[i].removeValue(forKey: "completedAt")
}
}
if let out = try? JSONSerialization.data(withJSONObject: arr) {
UserDefaults.kisani.set(out, forKey: key)
CloudSyncManager_widgetPush(out, key)
}
WidgetCenter.shared.reloadAllTimelines()
}
// The widget extension can't see CloudSyncManager; mirror to iCloud KV store directly.
private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
guard data.count < 900_000 else { return }
let kv = NSUbiquitousKeyValueStore.default
kv.set(data, forKey: key)
kv.synchronize()
}
// Today's task completion: (done, total) for tasks due today.
func todayTaskCompletion() -> (done: Int, total: Int) {
let cal = Calendar.current