Matrix (KC-21): the Eisenhower Matrix is now a computed view of tasks. Importance = Priority (High = top row), urgency = deadline proximity, so tasks auto-place into Q1–Q4. Added a persisted `matrixOverride` so a manual drag pins the task and syncs its Priority to that row (top → High, moving down demotes High → Medium) and never gets forced back; editing Priority, due date, or the editor clears the override to re-place live. New tasks get KisaniCal priority defaults (birthday/domain/annual/exam/subscription → High, workout → Medium). AddTaskSheet drops the manual quadrant picker for a Priority menu (pick Priority + Date only). Also includes in-progress Progress Dots widget work (day/week/month/custom event modes, App-Group anchor, recurrence-aware spans). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
6.6 KiB
Swift
175 lines
6.6 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
// Minimal task model shared between widget and main app via App Group
|
|
struct WidgetTask: Codable, Identifiable {
|
|
let id: UUID
|
|
let title: String
|
|
let dueDate: Date?
|
|
let isComplete: Bool
|
|
let quadrant: String // raw value of Quadrant enum
|
|
let category: String
|
|
var isRecurring: Bool = false
|
|
var recurrenceLabel: String? = nil
|
|
var createdAt: Date? = nil
|
|
var reminderDate: Date? = nil
|
|
var progress: Double? = nil
|
|
|
|
// Days remaining until due date
|
|
var daysLeft: Int? {
|
|
guard let due = dueDate else { return nil }
|
|
let d = Calendar.current.dateComponents([.day], from: Calendar.current.startOfDay(for: .now), to: due).day ?? 0
|
|
return max(0, d)
|
|
}
|
|
|
|
// Hours remaining (for sub-day precision)
|
|
var hoursLeft: Int? {
|
|
guard let due = dueDate else { return nil }
|
|
let h = Calendar.current.dateComponents([.hour], from: .now, to: due).hour ?? 0
|
|
return max(0, h)
|
|
}
|
|
|
|
var timeLeftLabel: String {
|
|
guard dueDate != nil else { return "" }
|
|
if isComplete { return "Done" }
|
|
let days = daysLeft ?? 0
|
|
if days == 0 {
|
|
let hrs = hoursLeft ?? 0
|
|
return hrs <= 0 ? "Due now" : "\(hrs)hr left"
|
|
}
|
|
if days < 30 { return "\(days)d left" }
|
|
let months = days / 30
|
|
let remDays = days % 30
|
|
if remDays == 0 { return "\(months)mth left" }
|
|
return "\(months)mth \(remDays)d..."
|
|
}
|
|
|
|
var accentColor: Color {
|
|
switch quadrant {
|
|
case "Urgent + Important": return Color(red: 0.96, green: 0.42, blue: 0.20)
|
|
case "Schedule It": return Color(red: 0.95, green: 0.78, blue: 0.10)
|
|
case "Delegate": return Color(red: 0.24, green: 0.58, blue: 0.95)
|
|
case "Eliminate": return Color(red: 0.20, green: 0.78, blue: 0.50)
|
|
default: return Color(red: 0.96, green: 0.42, blue: 0.20)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load tasks from the shared App Group UserDefaults
|
|
func loadWidgetTasks() -> [WidgetTask] {
|
|
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
|
let key = "kisani.tasks.v2.\(uid)"
|
|
guard let data = UserDefaults.kisani.data(forKey: key),
|
|
let raw = try? JSONDecoder().decode([RawTask].self, from: data)
|
|
else { return [] }
|
|
return raw.map {
|
|
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
|
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
|
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
|
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
|
progress: $0.countdownProgress ?? $0.progress)
|
|
}
|
|
}
|
|
|
|
// Partial decode of TaskItem — only the fields widgets need
|
|
private struct RawTask: Codable {
|
|
let id: UUID
|
|
let title: String
|
|
let dueDate: Date?
|
|
var isComplete: Bool = false
|
|
var quadrant: String = "Urgent + Important"
|
|
var category: String = "reminder"
|
|
var isRecurring: Bool? = nil
|
|
var recurrenceLabel: String? = nil
|
|
var createdAt: Date? = nil
|
|
var reminderDate: Date? = nil
|
|
var progress: Double? = nil
|
|
var countdownProgress: Double? = nil
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
|
case createdAt, reminderDate, progress, countdownProgress
|
|
}
|
|
}
|
|
|
|
// Next upcoming task with a due date (for default widget display)
|
|
func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
|
|
tasks
|
|
.filter { !$0.isComplete && $0.dueDate != nil }
|
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
|
.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()
|
|
}
|
|
|
|
// Workout streak count
|
|
func loadWorkoutStreak() -> Int {
|
|
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
|
let key = "kisani.workout.completedDates.\(uid)"
|
|
let dates = UserDefaults.kisani.stringArray(forKey: key) ?? []
|
|
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
|
let cal = Calendar.current
|
|
let dateSet = Set(dates)
|
|
var streak = 0
|
|
var check = cal.startOfDay(for: Date())
|
|
if !dateSet.contains(fmt.string(from: check)) {
|
|
check = cal.date(byAdding: .day, value: -1, to: check)!
|
|
}
|
|
while dateSet.contains(fmt.string(from: check)) {
|
|
streak += 1
|
|
check = cal.date(byAdding: .day, value: -1, to: check)!
|
|
}
|
|
return streak
|
|
}
|