Matrix: TickTick-style Priority×deadline view + Progress Dots widget (KC-21)

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>
This commit is contained in:
kutesir
2026-06-15 12:40:39 +03:00
parent 2b48509a35
commit 3592d2a9d8
11 changed files with 568 additions and 197 deletions

View File

@@ -405,6 +405,7 @@
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
@@ -416,8 +417,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -498,6 +499,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
@@ -588,6 +590,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
@@ -612,6 +615,7 @@
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
@@ -623,8 +627,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@@ -644,3 +644,41 @@ Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift`
3. A yearly birthday shows Mode B: bar spans last year's → this year's date. 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 4. Long-press the task again → "Stop Tracking Countdown" → widget falls back
to its configured mode. to its configured mode.
---
## KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline)
### Problem
The Matrix treated importance as the task's *stored quadrant* — you placed a
task in a row and only a manual "Move" changed it. TickTick instead treats the
Matrix as a **view**: it auto-places every task from its **Priority** and **due
date**, lets you drag freely between all four quadrants, and never forces a
dragged task back.
### Fix (model — `TaskItem.swift`)
- Added `matrixOverride: Quadrant?` (persisted, optional → old saves decode).
- Importance is now `priority == .high` (top row), not the stored quadrant.
Urgency stays deadline-driven (due today/overdue/≤ `urgentWindowDays`).
Auto-placement: Q1 = High+urgent, Q2 = High+later, Q3 = not-High+urgent,
Q4 = the rest. `displayQuadrant` returns `matrixOverride` if set, else auto.
- `moveToQuadrant` (drag/menu) now pins `matrixOverride` **and** syncs Priority
to the target row (top → High; moving down demotes High → Medium). The move
sticks — nothing forces it back.
- Editing Priority (`setPriority`), due date (`setDate`), or saving the editor
(`updateTask`) clears `matrixOverride` so the task re-places live.
- `addTask` applies KisaniCal defaults when no priority is picked: birthday /
domain / annual (+ title keywords exam, subscription, renew, expir) → High;
workout / gym / exercise → Medium.
### Fix (create UI — `TodayView.swift`)
- `AddTaskSheet` no longer has a manual **quadrant** selector. It now exposes a
**Priority** menu (flag icon). User picks Priority + Date only; placement is
computed. Adding from a Matrix quadrant seeds High for the top row.
### How to test
1. New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
2. Drag it to Q1 → stays in Q1 (priority becomes High, override set).
3. Drag a Q1 task to Q4 → High demotes to Medium, sticks in Q4.
4. Open the task, change its due date sooner → override clears, re-places by rule.
5. Add a "Mum's birthday" with no priority → auto-High.

View File

@@ -23,6 +23,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Recurrence: optional so existing saved tasks decode safely (missing key nil). // Recurrence: optional so existing saved tasks decode safely (missing key nil).
var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended) var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended)
var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences
// Matrix: a manual drag pins the task to a quadrant (overrides auto-placement).
// nil = auto-place from Priority × deadline. Optional so old saves decode safely.
var matrixOverride: Quadrant? = nil
} }
enum Priority: String, Codable, CaseIterable, Identifiable { enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -332,18 +335,21 @@ class TaskViewModel: ObservableObject {
return nil return nil
} }
// MARK: - Eisenhower Matrix (classic: you set importance, time sets urgency) // MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with manual override)
// //
// Importance is intrinsic taken from the task's assigned quadrant's row // The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed:
// (Q1/Q2 = important, Q3/Q4 = unimportant) and only changes when you move it. // Importance (top row) = Priority is High; Medium/Low/None = bottom row.
// Urgency is derived from the deadline: a task is urgent when its (next active) // Urgency (left column) = its (next active) due date is today/overdue/within
// due date is within `urgentWindowDays`. The displayed quadrant = importance × urgency, // `urgentWindowDays`; undated or far-out = right column.
// so important tasks slide Q2Q1 as they near, unimportant slide Q4Q3, and // So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest.
// recurring tasks roll forward (a completed occurrence is replaced by the next). // A manual drag (`moveToQuadrant`) pins the task via `matrixOverride` and syncs its
// Priority to the target row the move sticks and is never forced back. Editing the
// Priority or due date in the task view clears the override so it re-places live.
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
static let urgentWindowDays = 7 static let urgentWindowDays = 7
private func isImportantQuadrant(_ q: Quadrant) -> Bool { q == .urgent || q == .schedule } private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
/// The due date that drives urgency now: a recurring task's next active /// The due date that drives urgency now: a recurring task's next active
/// occurrence, or a one-off task's own due date. /// occurrence, or a one-off task's own due date.
@@ -359,9 +365,9 @@ class TaskViewModel: ObservableObject {
return days <= Self.urgentWindowDays // due soon / overdue return days <= Self.urgentWindowDays // due soon / overdue
} }
/// Where a task should appear in the Matrix right now (importance × urgency). /// Auto-placement from Priority (importance) × deadline (urgency).
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant { private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant {
switch (isImportantQuadrant(t.quadrant), isUrgent(t, on: today)) { switch (isImportant(t), isUrgent(t, on: today)) {
case (true, true): return .urgent case (true, true): return .urgent
case (true, false): return .schedule case (true, false): return .schedule
case (false, true): return .delegate_ case (false, true): return .delegate_
@@ -369,6 +375,11 @@ class TaskViewModel: ObservableObject {
} }
} }
/// Where a task appears in the Matrix now: a manual override wins, else auto-placement.
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
t.matrixOverride ?? autoQuadrant(t, on: today)
}
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant. /// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
/// Recurring tasks contribute their single next-active occurrence. /// Recurring tasks contribute their single next-active occurrence.
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] { func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
@@ -409,6 +420,7 @@ class TaskViewModel: ObservableObject {
func setDate(_ task: TaskItem, date: Date?) { func setDate(_ task: TaskItem, date: Date?) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].dueDate = date tasks[idx].dueDate = date
tasks[idx].matrixOverride = nil // deadline changed re-place from urgency
save() save()
} }
@@ -421,14 +433,40 @@ class TaskViewModel: ObservableObject {
func setPriority(_ task: TaskItem, priority: Priority) { func setPriority(_ task: TaskItem, priority: Priority) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].priority = priority tasks[idx].priority = priority
tasks[idx].matrixOverride = nil // priority changed re-place from importance
save() save()
} }
/// Manual drag/move in the Matrix. Pins the task to the target quadrant and syncs
/// its Priority to that row top row (Q1/Q2) becomes High; moving down out of the
/// top demotes HighMedium so it reads as unimportant. TickTick-style: it sticks.
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) { func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return } guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
tasks[idx].quadrant = quadrant tasks[idx].matrixOverride = quadrant
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (widget accent)
let importantRow = (quadrant == .urgent || quadrant == .schedule)
if importantRow {
tasks[idx].priority = .high
} else if tasks[idx].priority == .high {
tasks[idx].priority = .medium
}
save() save()
} }
/// KisaniCal priority defaults high-stakes events start High, workouts Medium.
/// Applied only when the user didn't pick a priority (== .none) at creation.
private func defaultPriority(category: TaskCategory, title: String) -> Priority {
switch category {
case .birthday, .domain, .annual: return .high
default: break
}
let t = title.lowercased()
if t.contains("birthday") || t.contains("exam") || t.contains("subscription")
|| t.contains("renew") || t.contains("domain") || t.contains("expir") { return .high }
if t.contains("workout") || t.contains("gym") || t.contains("exercise") { return .medium }
return .none
}
func addTask(title: String, dueDate: Date?, hasTime: Bool = false, func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false, taskColor: TaskColor = .orange, isRecurring: Bool = false,
@@ -436,12 +474,14 @@ class TaskViewModel: ObservableObject {
endDate: Date? = nil, isAllDay: Bool = false, endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil, priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) { constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
let resolvedPriority = priority == .none
? defaultPriority(category: category, title: title) : priority
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category, quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring, taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, endDate: endDate, isAllDay: isAllDay,
priority: priority, reminderDate: reminderDate, priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder) constantReminder: constantReminder)
item.recurrenceEnd = recurrenceEnd item.recurrenceEnd = recurrenceEnd
tasks.append(item) tasks.append(item)
@@ -463,6 +503,7 @@ class TaskViewModel: ObservableObject {
tasks[idx].reminderDate = reminderDate tasks[idx].reminderDate = reminderDate
tasks[idx].constantReminder = constantReminder tasks[idx].constantReminder = constantReminder
tasks[idx].recurrenceEnd = recurrenceEnd tasks[idx].recurrenceEnd = recurrenceEnd
tasks[idx].matrixOverride = nil // editor saved a new deadline re-place
save() save()
} }
@@ -488,4 +529,3 @@ class TaskViewModel: ObservableObject {
save() save()
} }
} }

View File

@@ -47,20 +47,23 @@ struct TaskMenuItems: View {
} }
Menu { Menu {
Button { onSetDate(task, cal.startOfDay(for: Date())) } label: { Button { onSetDate(task, preservingTime(on: Date())) } label: {
Label("Today", systemImage: "sun.max") Label("Today", systemImage: "calendar")
}
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
Label("Tomorrow", systemImage: "sun.max")
}
Button { onSetDate(task, preservingTime(on: nextMonday())) } label: {
Label("Next Monday", systemImage: "calendar.badge.clock")
} }
Button {
let d = cal.date(byAdding: .day, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Tomorrow", systemImage: "sunrise") }
Button {
let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Next Week", systemImage: "calendar") }
Divider() Divider()
if let onEdit {
Button { onEdit(task) } label: {
Label("Pick Date", systemImage: "calendar.badge.plus")
}
}
Button { onSetDate(task, nil) } label: { Button { onSetDate(task, nil) } label: {
Label("Remove Date", systemImage: "xmark.circle") Label("Clear", systemImage: "xmark.square")
} }
} label: { Label("Date", systemImage: "calendar.badge.clock") } } label: { Label("Date", systemImage: "calendar.badge.clock") }
@@ -87,7 +90,7 @@ struct TaskMenuItems: View {
} label: { Label("Tags", systemImage: "tag") } } label: { Label("Tags", systemImage: "tag") }
Button { onLiveActivity(task) } label: { Button { onLiveActivity(task) } label: {
Label("Add to Live Activity", systemImage: "pin.circle") Label(liveActivityTitle, systemImage: liveActivityIcon)
} }
Button { CountdownTracker.toggle(task) } label: { Button { CountdownTracker.toggle(task) } label: {
@@ -105,4 +108,36 @@ struct TaskMenuItems: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
} }
private var liveActivityTitle: String {
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
return "Remove from Live Activity"
}
return "Add to Live Activity"
}
private var liveActivityIcon: String {
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
return "pin.slash"
}
return "pin.circle"
}
private func dayOffset(_ value: Int) -> Date {
cal.date(byAdding: .day, value: value, to: Date()) ?? Date()
}
private func nextMonday() -> Date {
var comps = DateComponents()
comps.weekday = 2
return cal.nextDate(after: Date(), matching: comps, matchingPolicy: .nextTimePreservingSmallerComponents)
?? dayOffset(7)
}
private func preservingTime(on day: Date) -> Date {
let base = cal.startOfDay(for: day)
guard task.hasTime, let due = task.dueDate else { return base }
let parts = cal.dateComponents([.hour, .minute], from: due)
return cal.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: 0, of: base) ?? base
}
} }

View File

@@ -126,7 +126,11 @@ struct TodayView: View {
onPostponeTask: { taskVM.postpone($0) }, onPostponeTask: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) } onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
) )
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.top, 10) .padding(.top, 10)
@@ -139,7 +143,13 @@ struct TodayView: View {
tasks: taskVM.tomorrowTasks, tasks: taskVM.tomorrowTasks,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) }, onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) } onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
) )
} }
@@ -150,7 +160,13 @@ struct TodayView: View {
tasks: taskVM.next7DaysTasks, tasks: taskVM.next7DaysTasks,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) }, onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) } onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
) )
} }
@@ -161,7 +177,13 @@ struct TodayView: View {
tasks: taskVM.laterTasks, tasks: taskVM.laterTasks,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) }, onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) } onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) }
) )
} }
@@ -512,6 +534,10 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in } var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in } var onDelete: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
@State private var expanded = true @State private var expanded = true
@@ -599,25 +625,24 @@ struct OverdueCard: View {
@ViewBuilder @ViewBuilder
private func taskMenu(_ task: TaskItem) -> some View { private func taskMenu(_ task: TaskItem) -> some View {
Button { withAnimation { onToggle(task) } } label: { Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Label("Mark Complete", systemImage: "checkmark.circle") Label("Mark Complete", systemImage: "checkmark.circle")
} }
Button { onPostponeTask(task) } label: { Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
} }
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
Button { onEdit(task) } label: {
Label("Edit", systemImage: "pencil")
}
ShareLink(item: task.title) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider() Divider()
Button(role: .destructive) { onDelete(task) } label: { TaskMenuItems(
Label("Delete", systemImage: "trash") task: task,
} onPin: onPin,
onSetDate: onSetDate,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: onDelete
)
} }
} }
@@ -919,7 +944,13 @@ struct UpcomingSection: View {
let tasks: [TaskItem] let tasks: [TaskItem]
let onToggle: (TaskItem) -> Void let onToggle: (TaskItem) -> Void
let onPostpone: (TaskItem) -> Void let onPostpone: (TaskItem) -> Void
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void let onDelete: (TaskItem) -> Void
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
@State private var collapsed = false @State private var collapsed = false
@State private var showAll = false @State private var showAll = false
@@ -972,13 +1003,21 @@ struct UpcomingSection: View {
withAnimation(KisaniSpring.snappy) { onToggle(task) } withAnimation(KisaniSpring.snappy) { onToggle(task) }
} }
.contextMenu { .contextMenu {
Button { withAnimation { onPostpone(task) } } label: { Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
} }
Divider() Divider()
Button(role: .destructive) { withAnimation { onDelete(task) } } label: { TaskMenuItems(
Label("Delete", systemImage: "trash") task: task,
} onPin: onPin,
onSetDate: onSetDate,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
)
} }
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.bottom, 5) .padding(.bottom, 5)
@@ -1682,7 +1721,7 @@ struct AddTaskSheet: View {
@State private var rawText = "" @State private var rawText = ""
@State private var parsed = NLParsed() @State private var parsed = NLParsed()
@State private var selectedQuadrant: Quadrant @State private var selectedPriority: Priority = .none
@State private var selectedCategory: TaskCategory = .reminder @State private var selectedCategory: TaskCategory = .reminder
@State private var reminderDate: Date? = nil @State private var reminderDate: Date? = nil
@State private var constantReminder = false @State private var constantReminder = false
@@ -1694,7 +1733,9 @@ struct AddTaskSheet: View {
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) { init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
self.prefilledDate = prefilledDate self.prefilledDate = prefilledDate
self.prefilledQuadrant = prefilledQuadrant self.prefilledQuadrant = prefilledQuadrant
_selectedQuadrant = State(initialValue: prefilledQuadrant) // Adding from a Matrix quadrant: seed the Priority that lands the task there.
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
_selectedPriority = State(initialValue: importantRow ? .high : .none)
var p = NLParsed() var p = NLParsed()
p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date()) p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
_parsed = State(initialValue: p) _parsed = State(initialValue: p)
@@ -1762,27 +1803,18 @@ struct AddTaskSheet: View {
Spacer(minLength: 4) Spacer(minLength: 4)
// Quadrant // Priority drives Matrix placement (importance axis), TickTick-style
Menu { Menu {
ForEach(Quadrant.allCases) { q in ForEach(Priority.allCases) { p in
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: { Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
Label { Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
Text(q.matrixLabel)
} icon: {
Image(uiImage: quadrantBadgeImage(q))
}
} }
} }
} label: { } label: {
HStack(spacing: 3) { Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
Text(selectedQuadrant.roman) .font(.system(size: 15))
.font(AppFonts.mono(8, weight: .heavy)) .foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
.foregroundColor(.white) .frame(width: 42, height: 44)
.frame(width: 14, height: 14)
.background(selectedQuadrant.color)
.clipShape(Circle())
}
.frame(width: 42, height: 44)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1858,35 +1890,14 @@ struct AddTaskSheet: View {
private func submit() { private func submit() {
guard canAdd else { return } guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: selectedQuadrant, category: selectedCategory, quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder, reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd) recurrenceEnd: parsed.recurrenceEnd)
dismiss() dismiss()
} }
private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
let uiColor: UIColor
switch q {
case .urgent: uiColor = UIColor(AppColors.accent)
case .schedule: uiColor = UIColor(AppColors.yellow)
case .delegate_: uiColor = UIColor(AppColors.blue)
case .eliminate: uiColor = UIColor(AppColors.green)
}
let size = CGSize(width: 20, height: 20)
let renderer = UIGraphicsImageRenderer(size: size)
let img = renderer.image { ctx in
uiColor.setFill()
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
let label = q.roman
let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white]
let ts = label.size(withAttributes: attrs)
label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs)
}
return img.withRenderingMode(.alwaysOriginal)
}
} }
// MARK: - Task Date Picker Sheet // MARK: - Task Date Picker Sheet

View File

@@ -186,6 +186,7 @@ struct InViewTutorialCard: View {
@Binding var step: Int @Binding var step: Int
let onAdvance: () -> Void let onAdvance: () -> Void
let onSkip: () -> Void let onSkip: () -> Void
@Environment(\.colorScheme) private var cs
private var current: PageTip { tips[min(step, tips.count - 1)] } private var current: PageTip { tips[min(step, tips.count - 1)] }
private var isLast: Bool { step == tips.count - 1 } private var isLast: Bool { step == tips.count - 1 }
@@ -203,17 +204,17 @@ struct InViewTutorialCard: View {
} }
Text(current.title) Text(current.title)
.font(AppFonts.sans(15, weight: .bold)) .font(AppFonts.sans(15, weight: .bold))
.foregroundColor(.white) .foregroundColor(AppColors.text(cs))
Spacer() Spacer()
Button("Skip") { onSkip() } Button("Skip") { onSkip() }
.font(AppFonts.sans(11)) .font(AppFonts.sans(11))
.foregroundColor(.white.opacity(0.45)) .foregroundColor(AppColors.text3(cs))
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Text(current.body) Text(current.body)
.font(AppFonts.sans(13)) .font(AppFonts.sans(13))
.foregroundColor(.white.opacity(0.80)) .foregroundColor(AppColors.text2(cs))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineSpacing(2) .lineSpacing(2)
@@ -221,7 +222,7 @@ struct InViewTutorialCard: View {
HStack(spacing: 5) { HStack(spacing: 5) {
ForEach(0..<tips.count, id: \.self) { i in ForEach(0..<tips.count, id: \.self) { i in
Capsule() Capsule()
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20)) .fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
.frame(width: i == step ? 18 : 6, height: 6) .frame(width: i == step ? 18 : 6, height: 6)
.animation(KisaniSpring.micro, value: step) .animation(KisaniSpring.micro, value: step)
} }
@@ -243,8 +244,9 @@ struct InViewTutorialCard: View {
} }
} }
.padding(16) .padding(16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) .background(AppColors.surface(cs), in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 20).stroke(AppColors.border(cs), lineWidth: 1))
.shadow(color: Color.black.opacity(cs == .dark ? 0.28 : 0.08), radius: 18, x: 0, y: 10)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.id(step) .id(step)
.transition(.asymmetric( .transition(.asymmetric(

View File

@@ -36,15 +36,232 @@ struct KisaniProvider: TimelineProvider {
// MARK: - Day Progress Widget ("Day done %") // MARK: - Day Progress Widget ("Day done %")
enum ProgressDotMode: String, AppEnum {
case day
case week
case month
case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
.day: "This Day",
.week: "This Week",
.month: "This Month",
.customEvent: "Custom Event",
]
}
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Progress Dots"
static var description = IntentDescription("Track day, week, month, or a KisaniCal event.")
@Parameter(title: "Mode", default: .month)
var mode: ProgressDotMode
@Parameter(title: "Event")
var event: EventEntity?
static var parameterSummary: some ParameterSummary {
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
Summary("Show \(\.$mode) for \(\.$event)")
} otherwise: {
Summary("Show \(\.$mode)")
}
}
}
struct ProgressDotEntry: TimelineEntry {
let date: Date
let title: String
let progress: Double
let totalDots: Int
}
struct ProgressDotProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> ProgressDotEntry {
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
}
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
entry(for: configuration, at: .now)
}
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
let interval: TimeInterval = 5 * 60
let entries = (0..<72).map { step in
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
}
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
}
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
switch configuration.mode {
case .day:
let start = Calendar.current.startOfDay(for: date)
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .week:
let start = Self.startOfISOWeek(containing: date)
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .month:
let interval = Calendar.current.dateInterval(of: .month, for: date)
let start = interval?.start ?? date
let end = interval?.end ?? date
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .customEvent:
guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
let due = task.dueDate
else {
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
}
let start = Self.eventStartDate(for: task, due: due, now: date)
let computedProgress = Self.progress(now: date, start: start, end: due)
let storedProgress = task.progress.map(Self.clamp) ?? 0
return ProgressDotEntry(date: date, title: task.title,
progress: max(storedProgress, computedProgress),
totalDots: 100)
}
}
private static func progress(now: Date, start: Date, end: Date) -> Double {
let total = end.timeIntervalSince(start)
guard total > 0 else { return end <= now ? 1 : 0 }
return clamp(now.timeIntervalSince(start) / total)
}
private static func clamp(_ value: Double) -> Double {
min(1, max(0, value))
}
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
if let span = recurrenceSpan(for: task, due: due, now: now) {
return span.prev
}
let start = [task.createdAt, task.reminderDate]
.compactMap { $0 }
.filter { $0 < due }
.min()
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
}
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
let userDefaults = UserDefaults.kisani
let key = "kisani.widget.progressDots.anchor.\(key)"
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
return saved
}
userDefaults.set(now, forKey: key)
return now
}
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
let candidate = explicit ?? stored
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
let remaining = target.timeIntervalSince(now)
let elapsed = now.timeIntervalSince(candidate)
let total = target.timeIntervalSince(candidate)
let progress = total > 0 ? elapsed / total : 0
let looksLikeUpgradeAnchor = remaining > 14 * 86400
&& elapsed >= 0
&& elapsed < 3 * 86400
&& progress < (1.0 / 100.0)
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
}
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
let totalDays = min(365, max(30, remainingDays * 2))
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
}
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased()
if category == "birthday" || category == "annual" {
return recurrenceSpan(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now)
}
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
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 {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
private static func startOfISOWeek(containing date: Date) -> Date {
let cal = Calendar(identifier: .iso8601)
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
}
private static func dayTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE d"
return formatter.string(from: date)
}
private static func weekTitle(for start: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "Week of \(formatter.string(from: start))"
}
private static func monthTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
}
struct DayProgressWidget: Widget { struct DayProgressWidget: Widget {
let kind = "KisaniDayProgress" let kind = "KisaniDayProgress"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
ProgressDotView(period: .day, accent: WidgetTheme.accent) ProgressDotView(entry: entry, accent: WidgetTheme.accent)
} }
.configurationDisplayName("Day Progress") .configurationDisplayName("Progress Dots")
.description("How much of today has elapsed, as a dot grid.") .description("Track day, week, month, or a selected event as a dot grid.")
.supportedFamilies([.systemSmall, .systemMedium]) .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
} }
} }

View File

@@ -95,15 +95,15 @@ struct MyTasksView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Today") Text("Today")
.font(.system(size: 18, weight: .bold)) .font(.system(.body, design: .monospaced))
.foregroundColor(WidgetTheme.accent) .foregroundColor(WidgetTheme.accent)
Text("\(entry.openCount)") Text("\(entry.openCount)")
.font(.system(size: 15, weight: .semibold)) .font(.system(.body, design: .monospaced))
.foregroundColor(Color(white: 0.5)) .foregroundColor(Color(white: 0.5))
Spacer() Spacer()
Button(intent: AddTaskFromWidgetIntent()) { Button(intent: AddTaskFromWidgetIntent()) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16))
.foregroundColor(WidgetTheme.accent) .foregroundColor(WidgetTheme.accent)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -112,7 +112,7 @@ struct MyTasksView: View {
if entry.tasks.isEmpty { if entry.tasks.isEmpty {
Spacer() Spacer()
Text("All clear for today 🎉") Text("All clear for today 🎉")
.font(.system(size: 13)) .font(.system(.caption, design: .monospaced))
.foregroundColor(Color(white: 0.5)) .foregroundColor(Color(white: 0.5))
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
Spacer() Spacer()
@@ -127,7 +127,7 @@ struct MyTasksView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
Text(task.title) Text(task.title)
.font(.system(size: 14)) .font(.system(.caption, design: .monospaced))
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white) .foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
.strikethrough(task.isComplete, color: Color(white: 0.45)) .strikethrough(task.isComplete, color: Color(white: 0.45))
.lineLimit(1) .lineLimit(1)
@@ -136,7 +136,7 @@ struct MyTasksView: View {
} }
if entry.tasks.count > maxRows { if entry.tasks.count > maxRows {
Text("+\(entry.tasks.count - maxRows) more") Text("+\(entry.tasks.count - maxRows) more")
.font(.system(size: 11, weight: .medium)) .font(.system(.caption2, design: .monospaced))
.foregroundColor(Color(white: 0.4)) .foregroundColor(Color(white: 0.4))
.padding(.leading, 28) .padding(.leading, 28)
} }

View File

@@ -13,27 +13,36 @@ struct TaskLiveActivity: Widget {
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle") Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22)) .font(.system(size: 22))
.foregroundColor(context.state.isComplete ? .green : accent) .foregroundColor(context.state.isComplete ? .green : accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(context.attributes.title) Text(context.attributes.title)
.font(.system(size: 15, weight: .semibold)) .font(.system(.body, design: .monospaced))
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8)
.strikethrough(context.state.isComplete) .strikethrough(context.state.isComplete)
if let due = context.state.dueDate { if let due = context.state.dueDate {
Text(due, style: context.state.isComplete ? .date : .relative) Text(due, style: context.state.isComplete ? .date : .relative)
.font(.system(size: 12, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.foregroundColor(.white.opacity(0.6)) .foregroundColor(.white.opacity(0.6))
.lineLimit(1)
} }
} }
Spacer() .layoutPriority(1)
if let due = context.state.dueDate, !context.state.isComplete { if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer) Text(due, style: .timer)
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(.callout, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent) .foregroundColor(accent)
.frame(maxWidth: 70) .lineLimit(1)
.minimumScaleFactor(0.72)
.frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true)
} }
} }
.padding(.horizontal, 16) .padding(.leading, 14)
.padding(.trailing, 10)
.padding(.vertical, 12) .padding(.vertical, 12)
.activityBackgroundTint(WidgetTheme.background.opacity(0.9)) .activityBackgroundTint(WidgetTheme.background.opacity(0.9))
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
@@ -47,14 +56,17 @@ struct TaskLiveActivity: Widget {
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
if let due = context.state.dueDate, !context.state.isComplete { if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer) Text(due, style: .timer)
.font(.system(.body, design: .monospaced)) .font(.system(.callout, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent) .foregroundColor(accent)
.frame(maxWidth: 64) .lineLimit(1)
.minimumScaleFactor(0.75)
.frame(width: 86, alignment: .trailing)
} }
} }
DynamicIslandExpandedRegion(.center) { DynamicIslandExpandedRegion(.center) {
Text(context.attributes.title) Text(context.attributes.title)
.font(.system(size: 14, weight: .semibold)) .font(.system(.callout, design: .monospaced))
.lineLimit(1) .lineLimit(1)
} }
} compactLeading: { } compactLeading: {
@@ -63,8 +75,11 @@ struct TaskLiveActivity: Widget {
if let due = context.state.dueDate, !context.state.isComplete { if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer) Text(due, style: .timer)
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent) .foregroundColor(accent)
.frame(maxWidth: 44) .lineLimit(1)
.minimumScaleFactor(0.7)
.frame(width: 48, alignment: .trailing)
} }
} minimal: { } minimal: {
Image(systemName: "checklist").foregroundColor(accent) Image(systemName: "checklist").foregroundColor(accent)

View File

@@ -12,6 +12,9 @@ struct WidgetTask: Codable, Identifiable {
let category: String let category: String
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
// Days remaining until due date // Days remaining until due date
var daysLeft: Int? { var daysLeft: Int? {
@@ -63,7 +66,9 @@ func loadWidgetTasks() -> [WidgetTask] {
return raw.map { return raw.map {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate, 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) isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress)
} }
} }
@@ -77,8 +82,13 @@ private struct RawTask: Codable {
var category: String = "reminder" var category: String = "reminder"
var isRecurring: Bool? = nil var isRecurring: Bool? = nil
var recurrenceLabel: String? = 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 { enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
case createdAt, reminderDate, progress, countdownProgress
} }
} }

View File

@@ -13,6 +13,18 @@ enum WidgetTheme {
static let textFaint = Color.white.opacity(0.4) static let textFaint = Color.white.opacity(0.4)
} }
private extension String {
func widgetTitleCased() -> String {
split(separator: " ").map { rawWord in
let word = String(rawWord)
guard let first = word.first else { return word }
if word == word.uppercased(), word.count <= 5 { return word }
return first.uppercased() + word.dropFirst().lowercased()
}
.joined(separator: " ")
}
}
// MARK: - Vertical-bar progress (comb / equalizer style) // MARK: - Vertical-bar progress (comb / equalizer style)
struct BarGrid: View { struct BarGrid: View {
@@ -38,99 +50,79 @@ struct BarGrid: View {
} }
} }
// MARK: - Dot-grid progress (day / week / month / year) // MARK: - Dot-grid progress
enum TimePeriod { case day, week, month, year }
struct ProgressDotView: View { struct ProgressDotView: View {
let period: TimePeriod let entry: ProgressDotEntry
let accent: Color let accent: Color
@Environment(\.widgetFamily) private var family @Environment(\.widgetFamily) private var family
private var label: String { private var headerFont: Font {
switch period { switch family {
case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date()) case .systemLarge: return .system(.title3, design: .monospaced)
case .week: return "This week" case .accessoryRectangular: return .system(.caption, design: .monospaced)
case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date()) default: return .system(.body, design: .monospaced)
case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date())
} }
} }
private var progress: Double { private var spacing: CGFloat {
let cal = Calendar.current; let now = Date() family == .accessoryRectangular ? 4 : 10
switch period {
case .day:
let sod = cal.startOfDay(for: now)
return (now.timeIntervalSince(sod)) / 86400
case .week:
let comps = cal.dateComponents([.weekday], from: now)
let wd = (comps.weekday ?? 1) - 1
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
return (Double(wd) * 86400 + secIntoDay) / (7 * 86400)
case .month:
let range = cal.range(of: .day, in: .month, for: now)!
let day = cal.component(.day, from: now) - 1
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400)
case .year:
let start = cal.date(from: cal.dateComponents([.year], from: now))!
let end = cal.date(byAdding: .year, value: 1, to: start)!
return now.timeIntervalSince(start) / end.timeIntervalSince(start)
}
} }
private var subLabel: String { private var horizontalPadding: CGFloat {
let pct = Int(progress * 100) switch family {
return "\(pct)%" case .systemLarge: return 8
case .accessoryRectangular: return 0
default: return 4
}
} }
var body: some View { var body: some View {
let isMedium = family == .systemMedium let percent = Int((entry.progress * 100).rounded(.down))
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) { VStack(alignment: .leading, spacing: spacing) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text(label) Text(entry.title)
.font(.system(size: isMedium ? 22 : 14, weight: .bold)) .font(headerFont)
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.72)
Spacer() Spacer()
Text(subLabel) Text("\(percent)%")
.font(.system(size: isMedium ? 22 : 13, weight: .bold)) .font(headerFont)
.foregroundColor(Color(white: 0.5)) .foregroundColor(Color(white: 0.5))
.lineLimit(1)
} }
DotGrid(total: 100, filled: Int(progress * 100), color: accent) DotGridView(progress: entry.progress, totalDots: entry.totalDots, color: accent)
} }
.padding(.horizontal, isMedium ? 4 : 0) .padding(.horizontal, horizontalPadding)
.containerBackground(WidgetTheme.background, for: .widget) .containerBackground(WidgetTheme.background, for: .widget)
} }
} }
// MARK: - Circular dot grid (event countdown motif) // MARK: - Reusable circular dot grid
struct DotGrid: View { struct DotGridView: View {
let total: Int let progress: Double
let filled: Int var totalDots: Int = 100
let color: Color let color: Color
var gap: CGFloat = 3 var gap: CGFloat = 3
private var filledDots: Int {
Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down))
}
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let l = layout(for: total, in: geo.size) let layout = layout(for: totalDots, in: geo.size)
VStack(spacing: gap) { let columns = Array(repeating: GridItem(.fixed(layout.size), spacing: gap, alignment: .center),
ForEach(0..<l.rows, id: \.self) { row in count: layout.cols)
HStack(spacing: gap) { LazyVGrid(columns: columns, alignment: .center, spacing: gap) {
ForEach(0..<l.cols, id: \.self) { col in ForEach(0..<totalDots, id: \.self) { index in
let idx = row * l.cols + col dot(filled: index < filledDots, size: layout.size)
if idx < total {
dot(filled: idx < filled, size: l.size)
} else {
Color.clear.frame(width: l.size, height: l.size)
}
}
Spacer(minLength: 0)
}
} }
Spacer(minLength: 0)
} }
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
} }
@@ -145,17 +137,19 @@ struct DotGrid: View {
} }
} }
/// Pick the column count that yields the largest uniform circle that still fits private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat, width: CGFloat, height: CGFloat) {
/// every dot inside the available area an even grid that fills the widget. guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6, 6, 6) }
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) { var best = (cols: 1, rows: count, size: CGFloat(0), width: CGFloat(0), height: CGFloat(0))
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) }
var best = (cols: 1, rows: count, size: CGFloat(0))
for cols in 1...count { for cols in 1...count {
let rows = Int(ceil(Double(count) / Double(cols))) let rows = Int(ceil(Double(count) / Double(cols)))
let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols) let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols)
let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows) let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
let s = min(w, h) let s = min(w, h)
if s > best.size { best = (cols, rows, s) } let gridWidth = CGFloat(cols) * s + gap * CGFloat(cols - 1)
let gridHeight = CGFloat(rows) * s + gap * CGFloat(rows - 1)
if s > best.size && gridWidth <= size.width && gridHeight <= size.height {
best = (cols, rows, s, gridWidth, gridHeight)
}
} }
return best return best
} }
@@ -169,17 +163,22 @@ struct EventBarsView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(LocalizedStringKey(entry.eventName)) Text(entry.eventName.widgetTitleCased())
.font(.system(size: 22, weight: .heavy)) .font(.system(.body, design: .monospaced))
.foregroundColor(WidgetTheme.accent) .foregroundColor(WidgetTheme.accent)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.78)
.layoutPriority(1)
Button(intent: CycleCountdownUnitIntent()) { Button(intent: CycleCountdownUnitIntent()) {
Text(entry.fineTimeLeft) Text(entry.fineTimeLeft)
.font(.system(size: 16, weight: .semibold)) .font(.system(.body, design: .monospaced))
.foregroundColor(WidgetTheme.textDim) .foregroundColor(WidgetTheme.textDim)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.9)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
Spacer(minLength: 8) Spacer(minLength: 8)
@@ -200,10 +199,10 @@ struct LockScreenRectView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(task.title) Text(task.title)
.font(.system(size: 13, weight: .semibold)) .font(.system(.caption, design: .monospaced))
.lineLimit(1) .lineLimit(1)
Text(task.timeLeftLabel) Text(task.timeLeftLabel)
.font(.system(size: 11)) .font(.system(.caption2, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
@@ -235,10 +234,10 @@ struct LockScreenCircularView: View {
} }
VStack(spacing: 0) { VStack(spacing: 0) {
if let days = task.daysLeft { if let days = task.daysLeft {
Text("\(days)").font(.system(size: 14, weight: .bold)) Text("\(days)").font(.system(.caption, design: .monospaced))
Text("days").font(.system(size: 7)) Text("days").font(.system(.caption2, design: .monospaced))
} else { } else {
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold)) Image(systemName: "checkmark").font(.system(size: 14))
} }
} }
} }