From 3592d2a9d833538ebffb7332c153d8045a91fe0b Mon Sep 17 00:00:00 2001 From: kutesir Date: Mon, 15 Jun 2026 12:40:39 +0300 Subject: [PATCH] =?UTF-8?q?Matrix:=20TickTick-style=20Priority=C3=97deadli?= =?UTF-8?q?ne=20view=20+=20Progress=20Dots=20widget=20(KC-21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KisaniCal.xcodeproj/project.pbxproj | 12 +- KisaniCal/ISSUES.md | 38 ++++ KisaniCal/Models/TaskItem.swift | 68 +++++-- KisaniCal/Views/TaskContextMenu.swift | 59 ++++-- KisaniCal/Views/TodayView.swift | 139 ++++++++------- KisaniCal/Views/TutorialView.swift | 14 +- KisaniCalWidgets/KisaniCalWidgets.swift | 227 +++++++++++++++++++++++- KisaniCalWidgets/MyTasksWidget.swift | 12 +- KisaniCalWidgets/TaskLiveActivity.swift | 35 ++-- KisaniCalWidgets/WidgetData.swift | 12 +- KisaniCalWidgets/WidgetViews.swift | 149 ++++++++-------- 11 files changed, 568 insertions(+), 197 deletions(-) diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index ded2732..89a77ee 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -405,6 +405,7 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; @@ -416,8 +417,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -498,6 +499,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -588,6 +590,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -612,6 +615,7 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; @@ -623,8 +627,8 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index f797282..396a733 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. 4. Long-press the task again → "Stop Tracking Countdown" → widget falls back 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. diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index dd2c6a9..3a13603 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -23,6 +23,9 @@ struct TaskItem: Identifiable, Codable, Equatable { // Recurrence: optional so existing saved tasks decode safely (missing key → nil). var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended) 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 { @@ -332,18 +335,21 @@ class TaskViewModel: ObservableObject { 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 - // (Q1/Q2 = important, Q3/Q4 = unimportant) and only changes when you move it. - // Urgency is derived from the deadline: a task is urgent when its (next active) - // due date is within `urgentWindowDays`. The displayed quadrant = importance × urgency, - // so important tasks slide Q2→Q1 as they near, unimportant slide Q4→Q3, and - // recurring tasks roll forward (a completed occurrence is replaced by the next). + // The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed: + // • Importance (top row) = Priority is High; Medium/Low/None = bottom row. + // • Urgency (left column) = its (next active) due date is today/overdue/within + // `urgentWindowDays`; undated or far-out = right column. + // So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest. + // 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 - 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 /// occurrence, or a one-off task's own due date. @@ -359,9 +365,9 @@ class TaskViewModel: ObservableObject { return days <= Self.urgentWindowDays // due soon / overdue } - /// Where a task should appear in the Matrix right now (importance × urgency). - func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant { - switch (isImportantQuadrant(t.quadrant), isUrgent(t, on: today)) { + /// Auto-placement from Priority (importance) × deadline (urgency). + private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant { + switch (isImportant(t), isUrgent(t, on: today)) { case (true, true): return .urgent case (true, false): return .schedule 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. /// Recurring tasks contribute their single next-active occurrence. func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] { @@ -409,6 +420,7 @@ class TaskViewModel: ObservableObject { func setDate(_ task: TaskItem, date: Date?) { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[idx].dueDate = date + tasks[idx].matrixOverride = nil // deadline changed → re-place from urgency save() } @@ -421,14 +433,40 @@ class TaskViewModel: ObservableObject { func setPriority(_ task: TaskItem, priority: Priority) { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[idx].priority = priority + tasks[idx].matrixOverride = nil // priority changed → re-place from importance 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 High→Medium so it reads as unimportant. TickTick-style: it sticks. func moveToQuadrant(taskID: UUID, quadrant: Quadrant) { 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() } + /// 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, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, taskColor: TaskColor = .orange, isRecurring: Bool = false, @@ -436,12 +474,14 @@ class TaskViewModel: ObservableObject { endDate: Date? = nil, isAllDay: Bool = false, priority: Priority = .none, reminderDate: 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, quadrant: quadrant, category: category, taskColor: taskColor, isRecurring: isRecurring, recurrenceLabel: recurrenceLabel, endDate: endDate, isAllDay: isAllDay, - priority: priority, reminderDate: reminderDate, + priority: resolvedPriority, reminderDate: reminderDate, constantReminder: constantReminder) item.recurrenceEnd = recurrenceEnd tasks.append(item) @@ -463,6 +503,7 @@ class TaskViewModel: ObservableObject { tasks[idx].reminderDate = reminderDate tasks[idx].constantReminder = constantReminder tasks[idx].recurrenceEnd = recurrenceEnd + tasks[idx].matrixOverride = nil // editor saved a new deadline → re-place save() } @@ -488,4 +529,3 @@ class TaskViewModel: ObservableObject { save() } } - diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift index 3b526bb..f39e128 100644 --- a/KisaniCal/Views/TaskContextMenu.swift +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -47,20 +47,23 @@ struct TaskMenuItems: View { } Menu { - Button { onSetDate(task, cal.startOfDay(for: Date())) } label: { - Label("Today", systemImage: "sun.max") + Button { onSetDate(task, preservingTime(on: Date())) } label: { + 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() + if let onEdit { + Button { onEdit(task) } label: { + Label("Pick Date", systemImage: "calendar.badge.plus") + } + } Button { onSetDate(task, nil) } label: { - Label("Remove Date", systemImage: "xmark.circle") + Label("Clear", systemImage: "xmark.square") } } label: { Label("Date", systemImage: "calendar.badge.clock") } @@ -87,7 +90,7 @@ struct TaskMenuItems: View { } label: { Label("Tags", systemImage: "tag") } Button { onLiveActivity(task) } label: { - Label("Add to Live Activity", systemImage: "pin.circle") + Label(liveActivityTitle, systemImage: liveActivityIcon) } Button { CountdownTracker.toggle(task) } label: { @@ -105,4 +108,36 @@ struct TaskMenuItems: View { 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 + } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index b243c2d..f4f06de 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -126,7 +126,11 @@ struct TodayView: View { onPostponeTask: { taskVM.postpone($0) }, onPin: { taskVM.pin($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(.top, 10) @@ -139,7 +143,13 @@ struct TodayView: View { tasks: taskVM.tomorrowTasks, onToggle: { taskVM.toggle($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, onToggle: { taskVM.toggle($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, onToggle: { taskVM.toggle($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 onEdit: (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 @@ -599,25 +625,24 @@ struct OverdueCard: View { @ViewBuilder 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") } - Button { onPostponeTask(task) } label: { + Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: { 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() - Button(role: .destructive) { onDelete(task) } label: { - Label("Delete", systemImage: "trash") - } + TaskMenuItems( + 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 onToggle: (TaskItem) -> Void let onPostpone: (TaskItem) -> Void + var onPin: (TaskItem) -> Void = { _ in } + var onEdit: (TaskItem) -> Void = { _ in } 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 showAll = false @@ -972,13 +1003,21 @@ struct UpcomingSection: View { withAnimation(KisaniSpring.snappy) { onToggle(task) } } .contextMenu { - Button { withAnimation { onPostpone(task) } } label: { + Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: { Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") } Divider() - Button(role: .destructive) { withAnimation { onDelete(task) } } label: { - Label("Delete", systemImage: "trash") - } + TaskMenuItems( + 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(.bottom, 5) @@ -1682,7 +1721,7 @@ struct AddTaskSheet: View { @State private var rawText = "" @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 reminderDate: Date? = nil @State private var constantReminder = false @@ -1694,7 +1733,9 @@ struct AddTaskSheet: View { init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) { self.prefilledDate = prefilledDate 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() p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date()) _parsed = State(initialValue: p) @@ -1762,27 +1803,18 @@ struct AddTaskSheet: View { Spacer(minLength: 4) - // Quadrant + // Priority — drives Matrix placement (importance axis), TickTick-style Menu { - ForEach(Quadrant.allCases) { q in - Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: { - Label { - Text(q.matrixLabel) - } icon: { - Image(uiImage: quadrantBadgeImage(q)) - } + ForEach(Priority.allCases) { p in + Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: { + Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag") } } } label: { - HStack(spacing: 3) { - Text(selectedQuadrant.roman) - .font(AppFonts.mono(8, weight: .heavy)) - .foregroundColor(.white) - .frame(width: 14, height: 14) - .background(selectedQuadrant.color) - .clipShape(Circle()) - } - .frame(width: 42, height: 44) + Image(systemName: selectedPriority == .none ? "flag" : "flag.fill") + .font(.system(size: 15)) + .foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color) + .frame(width: 42, height: 44) } .buttonStyle(.plain) @@ -1858,35 +1890,14 @@ struct AddTaskSheet: View { private func submit() { guard canAdd else { return } taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, - quadrant: selectedQuadrant, category: selectedCategory, + quadrant: prefilledQuadrant, category: selectedCategory, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, endDate: parsed.endDate, isAllDay: parsed.isAllDay, + priority: selectedPriority, reminderDate: reminderDate, constantReminder: constantReminder, recurrenceEnd: parsed.recurrenceEnd) 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 diff --git a/KisaniCal/Views/TutorialView.swift b/KisaniCal/Views/TutorialView.swift index 957a24d..ae6a9c3 100644 --- a/KisaniCal/Views/TutorialView.swift +++ b/KisaniCal/Views/TutorialView.swift @@ -186,6 +186,7 @@ struct InViewTutorialCard: View { @Binding var step: Int let onAdvance: () -> Void let onSkip: () -> Void + @Environment(\.colorScheme) private var cs private var current: PageTip { tips[min(step, tips.count - 1)] } private var isLast: Bool { step == tips.count - 1 } @@ -203,17 +204,17 @@ struct InViewTutorialCard: View { } Text(current.title) .font(AppFonts.sans(15, weight: .bold)) - .foregroundColor(.white) + .foregroundColor(AppColors.text(cs)) Spacer() Button("Skip") { onSkip() } .font(AppFonts.sans(11)) - .foregroundColor(.white.opacity(0.45)) + .foregroundColor(AppColors.text3(cs)) .buttonStyle(.plain) } Text(current.body) .font(AppFonts.sans(13)) - .foregroundColor(.white.opacity(0.80)) + .foregroundColor(AppColors.text2(cs)) .fixedSize(horizontal: false, vertical: true) .lineSpacing(2) @@ -221,7 +222,7 @@ struct InViewTutorialCard: View { HStack(spacing: 5) { ForEach(0.. 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 { + 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 { let kind = "KisaniDayProgress" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in - ProgressDotView(period: .day, accent: WidgetTheme.accent) + AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in + ProgressDotView(entry: entry, accent: WidgetTheme.accent) } - .configurationDisplayName("Day Progress") - .description("How much of today has elapsed, as a dot grid.") - .supportedFamilies([.systemSmall, .systemMedium]) + .configurationDisplayName("Progress Dots") + .description("Track day, week, month, or a selected event as a dot grid.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular]) } } diff --git a/KisaniCalWidgets/MyTasksWidget.swift b/KisaniCalWidgets/MyTasksWidget.swift index 904c12e..7a889de 100644 --- a/KisaniCalWidgets/MyTasksWidget.swift +++ b/KisaniCalWidgets/MyTasksWidget.swift @@ -95,15 +95,15 @@ struct MyTasksView: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Text("Today") - .font(.system(size: 18, weight: .bold)) + .font(.system(.body, design: .monospaced)) .foregroundColor(WidgetTheme.accent) Text("\(entry.openCount)") - .font(.system(size: 15, weight: .semibold)) + .font(.system(.body, design: .monospaced)) .foregroundColor(Color(white: 0.5)) Spacer() Button(intent: AddTaskFromWidgetIntent()) { Image(systemName: "plus") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 16)) .foregroundColor(WidgetTheme.accent) } .buttonStyle(.plain) @@ -112,7 +112,7 @@ struct MyTasksView: View { if entry.tasks.isEmpty { Spacer() Text("All clear for today 🎉") - .font(.system(size: 13)) + .font(.system(.caption, design: .monospaced)) .foregroundColor(Color(white: 0.5)) .frame(maxWidth: .infinity, alignment: .center) Spacer() @@ -127,7 +127,7 @@ struct MyTasksView: View { .buttonStyle(.plain) Text(task.title) - .font(.system(size: 14)) + .font(.system(.caption, design: .monospaced)) .foregroundColor(task.isComplete ? Color(white: 0.45) : .white) .strikethrough(task.isComplete, color: Color(white: 0.45)) .lineLimit(1) @@ -136,7 +136,7 @@ struct MyTasksView: View { } if entry.tasks.count > maxRows { Text("+\(entry.tasks.count - maxRows) more") - .font(.system(size: 11, weight: .medium)) + .font(.system(.caption2, design: .monospaced)) .foregroundColor(Color(white: 0.4)) .padding(.leading, 28) } diff --git a/KisaniCalWidgets/TaskLiveActivity.swift b/KisaniCalWidgets/TaskLiveActivity.swift index 1374b8a..278ff30 100644 --- a/KisaniCalWidgets/TaskLiveActivity.swift +++ b/KisaniCalWidgets/TaskLiveActivity.swift @@ -13,27 +13,36 @@ struct TaskLiveActivity: Widget { Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle") .font(.system(size: 22)) .foregroundColor(context.state.isComplete ? .green : accent) + .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(context.attributes.title) - .font(.system(size: 15, weight: .semibold)) + .font(.system(.body, design: .monospaced)) .foregroundColor(.white) .lineLimit(1) + .minimumScaleFactor(0.8) .strikethrough(context.state.isComplete) if let due = context.state.dueDate { Text(due, style: context.state.isComplete ? .date : .relative) - .font(.system(size: 12, design: .monospaced)) + .font(.system(.caption, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) + .lineLimit(1) } } - Spacer() + .layoutPriority(1) + if let due = context.state.dueDate, !context.state.isComplete { Text(due, style: .timer) - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.system(.callout, design: .monospaced)) + .monospacedDigit() .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) .activityBackgroundTint(WidgetTheme.background.opacity(0.9)) .activitySystemActionForegroundColor(.white) @@ -47,14 +56,17 @@ struct TaskLiveActivity: Widget { DynamicIslandExpandedRegion(.trailing) { if let due = context.state.dueDate, !context.state.isComplete { Text(due, style: .timer) - .font(.system(.body, design: .monospaced)) + .font(.system(.callout, design: .monospaced)) + .monospacedDigit() .foregroundColor(accent) - .frame(maxWidth: 64) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(width: 86, alignment: .trailing) } } DynamicIslandExpandedRegion(.center) { Text(context.attributes.title) - .font(.system(size: 14, weight: .semibold)) + .font(.system(.callout, design: .monospaced)) .lineLimit(1) } } compactLeading: { @@ -63,8 +75,11 @@ struct TaskLiveActivity: Widget { if let due = context.state.dueDate, !context.state.isComplete { Text(due, style: .timer) .font(.system(.caption2, design: .monospaced)) + .monospacedDigit() .foregroundColor(accent) - .frame(maxWidth: 44) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: 48, alignment: .trailing) } } minimal: { Image(systemName: "checklist").foregroundColor(accent) diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift index 90c0697..75ee4de 100644 --- a/KisaniCalWidgets/WidgetData.swift +++ b/KisaniCalWidgets/WidgetData.swift @@ -12,6 +12,9 @@ struct WidgetTask: Codable, Identifiable { 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? { @@ -63,7 +66,9 @@ func loadWidgetTasks() -> [WidgetTask] { 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) + 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 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 } } diff --git a/KisaniCalWidgets/WidgetViews.swift b/KisaniCalWidgets/WidgetViews.swift index b0f32a0..01dce79 100644 --- a/KisaniCalWidgets/WidgetViews.swift +++ b/KisaniCalWidgets/WidgetViews.swift @@ -13,6 +13,18 @@ enum WidgetTheme { 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) struct BarGrid: View { @@ -38,99 +50,79 @@ struct BarGrid: View { } } -// MARK: - Dot-grid progress (day / week / month / year) - -enum TimePeriod { case day, week, month, year } +// MARK: - Dot-grid progress struct ProgressDotView: View { - let period: TimePeriod + let entry: ProgressDotEntry let accent: Color @Environment(\.widgetFamily) private var family - private var label: String { - switch period { - case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date()) - case .week: return "This week" - case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date()) - case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date()) + private var headerFont: Font { + switch family { + case .systemLarge: return .system(.title3, design: .monospaced) + case .accessoryRectangular: return .system(.caption, design: .monospaced) + default: return .system(.body, design: .monospaced) } } - private var progress: Double { - let cal = Calendar.current; let now = Date() - 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 spacing: CGFloat { + family == .accessoryRectangular ? 4 : 10 } - private var subLabel: String { - let pct = Int(progress * 100) - return "\(pct)%" + private var horizontalPadding: CGFloat { + switch family { + case .systemLarge: return 8 + case .accessoryRectangular: return 0 + default: return 4 + } } var body: some View { - let isMedium = family == .systemMedium - VStack(alignment: .leading, spacing: isMedium ? 10 : 6) { + let percent = Int((entry.progress * 100).rounded(.down)) + VStack(alignment: .leading, spacing: spacing) { HStack(alignment: .firstTextBaseline) { - Text(label) - .font(.system(size: isMedium ? 22 : 14, weight: .bold)) + Text(entry.title) + .font(headerFont) .foregroundColor(.white) .lineLimit(1) + .minimumScaleFactor(0.72) Spacer() - Text(subLabel) - .font(.system(size: isMedium ? 22 : 13, weight: .bold)) + Text("\(percent)%") + .font(headerFont) .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) } } -// MARK: - Circular dot grid (event countdown motif) +// MARK: - Reusable circular dot grid -struct DotGrid: View { - let total: Int - let filled: Int +struct DotGridView: View { + let progress: Double + var totalDots: Int = 100 let color: Color var gap: CGFloat = 3 + private var filledDots: Int { + Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down)) + } + var body: some View { GeometryReader { geo in - let l = layout(for: total, in: geo.size) - VStack(spacing: gap) { - ForEach(0.. (cols: Int, rows: Int, size: CGFloat) { - guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) } - var best = (cols: 1, rows: count, size: CGFloat(0)) + private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat, width: CGFloat, height: CGFloat) { + guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6, 6, 6) } + var best = (cols: 1, rows: count, size: CGFloat(0), width: CGFloat(0), height: CGFloat(0)) for cols in 1...count { let rows = Int(ceil(Double(count) / Double(cols))) let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols) let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows) 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 } @@ -169,17 +163,22 @@ struct EventBarsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(LocalizedStringKey(entry.eventName)) - .font(.system(size: 22, weight: .heavy)) + Text(entry.eventName.widgetTitleCased()) + .font(.system(.body, design: .monospaced)) .foregroundColor(WidgetTheme.accent) .lineLimit(1) + .minimumScaleFactor(0.78) + .layoutPriority(1) Button(intent: CycleCountdownUnitIntent()) { Text(entry.fineTimeLeft) - .font(.system(size: 16, weight: .semibold)) + .font(.system(.body, design: .monospaced)) .foregroundColor(WidgetTheme.textDim) .lineLimit(1) + .minimumScaleFactor(0.9) } .buttonStyle(.plain) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) Spacer(minLength: 0) } Spacer(minLength: 8) @@ -200,10 +199,10 @@ struct LockScreenRectView: View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text(task.title) - .font(.system(size: 13, weight: .semibold)) + .font(.system(.caption, design: .monospaced)) .lineLimit(1) Text(task.timeLeftLabel) - .font(.system(size: 11)) + .font(.system(.caption2, design: .monospaced)) .foregroundColor(.secondary) } Spacer() @@ -235,10 +234,10 @@ struct LockScreenCircularView: View { } VStack(spacing: 0) { if let days = task.daysLeft { - Text("\(days)").font(.system(size: 14, weight: .bold)) - Text("days").font(.system(size: 7)) + Text("\(days)").font(.system(.caption, design: .monospaced)) + Text("days").font(.system(.caption2, design: .monospaced)) } else { - Image(systemName: "checkmark").font(.system(size: 14, weight: .bold)) + Image(systemName: "checkmark").font(.system(size: 14)) } } }