import SwiftUI import WatchKit // MARK: - Shared iCloud task store // // The Watch is a separate device and can't read the iPhone's App Group, so it // reads/writes the SAME tasks via the iCloud key-value store (NSUbiquitousKeyValueStore) // that CloudSyncManager already mirrors on the phone. Writes here propagate back to // the phone, which applies external KV changes into its App Group. private func tasksKey() -> String { let uid = NSUbiquitousKeyValueStore.default.string(forKey: "kisani.activeUserId") ?? "shared" return "kisani.tasks.v2.\(uid)" } private let occFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f }() struct WatchTask: Identifiable { let id: UUID var title: String var dueDate: Date? var isComplete: Bool var isRecurring: Bool var recurrenceLabel: String? var completedOccurrences: [String] } final class WatchStore: ObservableObject { @Published var tasks: [WatchTask] = [] private let kv = NSUbiquitousKeyValueStore.default private let cal = Calendar.current init() { NotificationCenter.default.addObserver( self, selector: #selector(externalChange), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv) kv.synchronize() load() } @objc private func externalChange() { kv.synchronize() DispatchQueue.main.async { [weak self] in self?.load() } } func load() { guard let data = kv.data(forKey: tasksKey()), let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { tasks = []; return } tasks = arr.compactMap { d in guard let idStr = d["id"] as? String, let id = UUID(uuidString: idStr), let title = d["title"] as? String else { return nil } let due = (d["dueDate"] as? Double).map { Date(timeIntervalSinceReferenceDate: $0) } return WatchTask(id: id, title: title, dueDate: due, isComplete: d["isComplete"] as? Bool ?? false, isRecurring: d["isRecurring"] as? Bool ?? false, recurrenceLabel: d["recurrenceLabel"] as? String, completedOccurrences: d["completedOccurrences"] as? [String] ?? []) } } // Today's actionable tasks: dated today/overdue, plus recurring occurrences due today. var todayTasks: [WatchTask] { let end = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: Date()))! return tasks.filter { t in if t.isRecurring { return occursToday(t) && !completedToday(t) } guard !t.isComplete, let due = t.dueDate else { return false } return due < end } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } } private func completedToday(_ t: WatchTask) -> Bool { t.completedOccurrences.contains(occFmt.string(from: Date())) } private func occursToday(_ t: WatchTask) -> Bool { guard let due = t.dueDate else { return false } let now = Date() if due > cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: now))! { return false } switch t.recurrenceLabel { case "Daily": return true case "Every Weekday": let wd = cal.component(.weekday, from: now); return wd >= 2 && wd <= 6 case "Weekly": return cal.component(.weekday, from: due) == cal.component(.weekday, from: now) case "Monthly": return cal.component(.day, from: due) == cal.component(.day, from: now) case "Yearly": return cal.component(.month, from: due) == cal.component(.month, from: now) && cal.component(.day, from: due) == cal.component(.day, from: now) default: return false } } func toggle(_ task: WatchTask) { mutate { arr in for i in arr.indices where (arr[i]["id"] as? String) == task.id.uuidString { if task.isRecurring { var occ = arr[i]["completedOccurrences"] as? [String] ?? [] let k = occFmt.string(from: Date()) if let idx = occ.firstIndex(of: k) { occ.remove(at: idx) } else { occ.append(k) } arr[i]["completedOccurrences"] = occ } else { let done = (arr[i]["isComplete"] as? Bool) ?? false arr[i]["isComplete"] = !done if !done { arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate } else { arr[i].removeValue(forKey: "completedAt") } } } } } func add(_ title: String) { let t = title.trimmingCharacters(in: .whitespaces) guard !t.isEmpty else { return } mutate { arr in // All non-optional TaskItem keys must be present (synthesized Decodable // throws on missing keys); raw values mirror the iOS model. arr.append([ "id": UUID().uuidString, "title": t, "dueDate": self.cal.startOfDay(for: Date()).timeIntervalSinceReferenceDate, "hasTime": false, "isComplete": false, "quadrant": "Urgent + Important", "category": "Reminder", "taskColor": "orange", "isRecurring": false, "isPinned": false, "isAllDay": false, "priority": "None", "constantReminder": false, ]) } } private func mutate(_ change: (inout [[String: Any]]) -> Void) { let key = tasksKey() var arr: [[String: Any]] = [] if let data = kv.data(forKey: key), let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { arr = parsed } change(&arr) if let out = try? JSONSerialization.data(withJSONObject: arr) { kv.set(out, forKey: key) kv.synchronize() } load() } } // MARK: - UI @main struct WenzaWatchApp: App { var body: some Scene { WindowGroup { TaskListView() } } } struct TaskListView: View { @StateObject private var store = WatchStore() @State private var showAdd = false private let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }() var body: some View { NavigationStack { List { Button { showAdd = true } label: { Label("Add Task", systemImage: "plus.circle.fill") .foregroundStyle(.orange) } if store.todayTasks.isEmpty { Text("All caught up") .foregroundStyle(.secondary) .font(.footnote) } else { ForEach(store.todayTasks) { task in Button { withAnimation { WKInterfaceDevice.current().play(.success); store.toggle(task) } } label: { HStack(spacing: 8) { Image(systemName: "circle") .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 1) { Text(task.title).lineLimit(2) if let due = task.dueDate, !task.isRecurring { Text(timeFmt.string(from: due)) .font(.caption2).foregroundStyle(.secondary) } } } } } } } .navigationTitle("Today") .sheet(isPresented: $showAdd) { AddTaskView { store.add($0); showAdd = false } } .onAppear { store.reload() } } } } struct AddTaskView: View { let onAdd: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var text = "" var body: some View { VStack(spacing: 10) { TextField("New task", text: $text) // Watch dictation / Scribble Button("Add") { onAdd(text) } .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) { dismiss() } } .padding(.horizontal, 4) } }