From ac147c55b03211c7f89fc62f8576e4f27eede0e5 Mon Sep 17 00:00:00 2001 From: kutesir Date: Mon, 15 Jun 2026 18:14:10 +0300 Subject: [PATCH] Matrix/menus: Pick Date everywhere, time-based reminders, notification snooze (KC-21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Date menu now offers "Pick Date" (and Edit) in the Matrix grid cards and the Calendar reschedule/move menu, matching the tasks view — both open the shared TaskEditSheet via a new editingTask sheet. - Reworked the reminder picker (TaskDatePickerSheet, shared by all entry points) from day/week offsets to sensible time-based ones relative to the event time: None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing absolute reminderDates migrate to the nearest minute offset. - Notifications gain Snooze actions (15/30/60/120 min) that reschedule the task reminder; postpone now clears the Matrix urgency override. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/ISSUES.md | 102 ++++++++-- KisaniCal/Managers/NotificationManager.swift | 39 +++- KisaniCal/Models/TaskItem.swift | 18 ++ KisaniCal/Views/CalendarView.swift | 11 ++ KisaniCal/Views/MatrixView.swift | 22 +++ KisaniCal/Views/TaskContextMenu.swift | 26 ++- KisaniCal/Views/TodayView.swift | 187 +++++++++---------- 7 files changed, 286 insertions(+), 119 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 396a733..f046f00 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -649,24 +649,36 @@ Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift` ## KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline) +**Status:** In Progress (implemented & build-verified, pending device QA) +**Reported by:** User +**Area:** Tasks / Matrix + ### 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. +date**, while still allowing quick drag/menu moves. ### Fix (model — `TaskItem.swift`) -- Added `matrixOverride: Quadrant?` (persisted, optional → old saves decode). +- Replaced whole-quadrant override with `urgencyOverride: Bool?`. + - `nil` = urgency is computed from due date. + - `true` = force urgent column. + - `false` = force not-urgent column. - Importance is now `priority == .high` (top row), not the stored quadrant. - Urgency stays deadline-driven (due today/overdue/≤ `urgentWindowDays`). + Medium/Low/None stay in the bottom row. +- Urgency is deadline-driven: + - default urgent window = 3 days + - birthday/domain/annual/renewal/subscription/exam/deadline = 14 days 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. + Q4 = the rest. +- `moveToQuadrant` updates real attributes: + - vertical movement sets Priority (top → High; moving down demotes High → Medium) + - horizontal movement sets `urgencyOverride` only if the target column disagrees + with date-derived urgency. +- Editing due date (`setDate`) or saving the editor (`updateTask`) clears + `urgencyOverride` so the task re-places live. +- Category changes, priority changes, and date changes refresh the legacy + `quadrant` color/widget fallback to match computed placement. - `addTask` applies KisaniCal defaults when no priority is picked: birthday / domain / annual (+ title keywords exam, subscription, renew, expir) → High; workout / gym / exercise → Medium. @@ -675,10 +687,72 @@ dragged task back. - `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. +- Fixed no-date creation: a task without an explicit parsed/picked date stays + "No Date" instead of silently becoming today/urgent. + +### Fix (Matrix UI — `MatrixView.swift`, `TaskContextMenu.swift`) +- Completed tasks are hidden from the 2×2 Matrix grid by default, but remain in + the per-quadrant detail Completed section. +- Rows with an urgency override show a small **Manual** badge. +- Context menu includes **Reset Matrix Urgency** when an urgency override exists. +- Move menu shows all four quadrants, with a checkmark on the current quadrant. ### 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. +2. New task with no date → stays in right column until given a due date/reminder. +3. Drag Q2 → Q1 when due date is far out → Manual badge appears. +4. Reset Matrix Urgency → task returns to its date-derived column. +5. Drag Q1 → Q4 → High demotes to Medium and urgency is overridden if needed. +6. Open the task, change its due date → urgency override clears, re-places by rule. +7. Add a "Mum's birthday" with no priority → auto-High and uses 14-day urgency window. + +--- + +## KC-22 — Quick postpone / snooze from task menus and lock screen + +**Status:** In Progress (implemented & build-verified, pending device QA) +**Reported by:** User +**Area:** Tasks / Calendar / Matrix / Notifications + +### Problem +Long-pressing a task/event in Today, Matrix, or Calendar required going into the +date editor or using only coarse "+1 day" actions. Lock-screen task reminders +also only supported "Mark Complete". The user wanted standard task-manager +snooze choices directly from the pressed item and notification actions. + +### Fix (app task menus) +- Added a shared **Postpone** submenu to `TaskMenuItems`: + - 15 Minutes + - 30 Minutes + - 1 Hour + - 2 Hours + - Tomorrow +- Wired the submenu through: + - Today timeline + overdue/upcoming sections + - Matrix grid + quadrant detail sections + - Calendar day timeline +- `TaskViewModel.postpone(_:minutes:)` applies the nudge: + - existing reminder → reminder moves forward + - due date with no reminder → due date/time moves forward and becomes timed + - no date/reminder → creates a reminder + +### Fix (lock screen notifications) +- Task reminder notifications now include actions: + - 15 min + - 30 min + - 1 hour + - 2 hours +- Notification response handler updates the stored task reminder and schedules + a one-off snoozed notification for the selected delay. + +Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`, +`MatrixView.swift`, `CalendarView.swift`, `NotificationManager.swift`. + +### How to test +1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves. +2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline. +3. Open Move from the same menu → all four quadrants are visible, current one checked. +4. Trigger a task reminder notification on device → verify 15/30/60/120 minute + lock-screen actions appear. +5. Tap a lock-screen snooze action → task reminder updates and notification + re-delivers after the selected interval. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 671863c..38d9540 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -14,6 +14,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable private static let categoryId = "TASK_REMINDER" private static let actionMarkId = "MARK_COMPLETE" + private static let taskSnoozePrefix = "TASK_SNOOZE_" private static let confirmCatId = "WORKOUT_CONFIRM" private static let logWorkoutActId = "LOG_WORKOUT" private static let missedWorkoutActId = "MISSED_WORKOUT" @@ -28,7 +29,13 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable private func registerCategories() { let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: []) - let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction], + let taskSnoozeActions = [ + UNNotificationAction(identifier: Self.taskSnoozePrefix + "15", title: "15 min", options: []), + UNNotificationAction(identifier: Self.taskSnoozePrefix + "30", title: "30 min", options: []), + UNNotificationAction(identifier: Self.taskSnoozePrefix + "60", title: "1 hour", options: []), + UNNotificationAction(identifier: Self.taskSnoozePrefix + "120", title: "2 hours", options: []) + ] + let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction] + taskSnoozeActions, intentIdentifiers: [], options: .customDismissAction) let logAction = UNNotificationAction(identifier: Self.logWorkoutActId, @@ -59,6 +66,29 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable } } + private func snoozeTask(taskId: String, userId: String, minutes: Int, content: UNNotificationContent) { + let key = "kisani.tasks.v2.\(userId)" + let fireDate = Date().addingTimeInterval(TimeInterval(minutes * 60)) + if let data = UserDefaults.kisani.data(forKey: key), + var tasks = try? JSONDecoder().decode([TaskItem].self, from: data), + let uuid = UUID(uuidString: taskId), + let idx = tasks.firstIndex(where: { $0.id == uuid }) { + tasks[idx].reminderDate = fireDate + if let encoded = try? JSONEncoder().encode(tasks) { + UserDefaults.kisani.set(encoded, forKey: key) + } + } + + let mutable = content.mutableCopy() as! UNMutableNotificationContent + mutable.categoryIdentifier = Self.categoryId + mutable.userInfo = content.userInfo + center.add(UNNotificationRequest( + identifier: "kisani.task.snooze.\(taskId).\(minutes).\(UUID().uuidString)", + content: mutable, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(minutes * 60), repeats: false) + )) + } + // MARK: - Permission func requestPermission() { @@ -425,6 +455,13 @@ extension NotificationManager: UNUserNotificationCenterDelegate { case Self.actionMarkId: if let taskId { markTaskComplete(taskId: taskId, userId: userId) } + case let action where action.hasPrefix(Self.taskSnoozePrefix): + if let taskId, + let minutes = Int(action.replacingOccurrences(of: Self.taskSnoozePrefix, with: "")) { + snoozeTask(taskId: taskId, userId: userId, minutes: minutes, + content: response.notification.request.content) + } + case Self.logWorkoutActId: // "Yes, log it" from workout confirm notification — store date for WorkoutViewModel to pick up on next foreground let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index f141beb..4d1416e 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -562,6 +562,24 @@ class TaskViewModel: ObservableObject { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } let base = tasks[idx].dueDate ?? Date() tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base) + tasks[idx].urgencyOverride = nil + tasks[idx].quadrant = displayQuadrant(tasks[idx]) + save() + } + + func postpone(_ task: TaskItem, minutes: Int) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + let target = Date().addingTimeInterval(TimeInterval(minutes * 60)) + if tasks[idx].reminderDate != nil { + tasks[idx].reminderDate = target + } else if tasks[idx].dueDate != nil { + tasks[idx].dueDate = target + tasks[idx].hasTime = true + tasks[idx].urgencyOverride = nil + tasks[idx].quadrant = displayQuadrant(tasks[idx]) + } else { + tasks[idx].reminderDate = target + } save() } diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index e997b71..1b3ae92 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -298,6 +298,7 @@ struct CalendarView: View { @State private var showCalendarConnect = false @State private var showWorkoutSession = false @State private var editingEvent: EditableEvent? = nil + @State private var editingTask: TaskItem? = nil private let cal = Calendar.current private let weekdays = ["S","M","T","W","T","F","S"] @@ -411,6 +412,12 @@ struct CalendarView: View { } .ignoresSafeArea() } + .sheet(item: $editingTask) { task in + TaskEditSheet(task: task) + .environmentObject(taskVM) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .onAppear { calStore.refreshStatus() if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) } @@ -602,8 +609,10 @@ struct CalendarView: View { withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } }, onPin: { taskVM.pin($0) }, + onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -1583,6 +1592,7 @@ struct DayTimelineView: View { var onEdit: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil var onSetDate: ((TaskItem, Date?) -> Void)? = nil + var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil var onMove: ((TaskItem, Quadrant) -> Void)? = nil var onSetPriority: ((TaskItem, Priority) -> Void)? = nil var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil @@ -1746,6 +1756,7 @@ struct DayTimelineView: View { task: task, onPin: { onPin?($0) }, onSetDate: { onSetDate?($0, $1) }, + onPostponeMinutes: { onPostponeMinutes?($0, $1) }, onMove: { onMove?($0, $1) }, onSetPriority: { onSetPriority?($0, $1) }, onSetCategory: { onSetCategory?($0, $1) }, diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index 353f434..fc62fa1 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -9,6 +9,7 @@ struct MatrixView: View { @State private var showUserGuide = false @State private var drillQuadrant: Quadrant? = nil @State private var showDrill = false + @State private var editingTask: TaskItem? = nil var body: some View { ZStack(alignment: .bottomTrailing) { @@ -64,10 +65,12 @@ struct MatrixView: View { onDropExit: { dropTarget = nil }, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, + onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .urgent; showDrill = true } ) @@ -83,10 +86,12 @@ struct MatrixView: View { onDropExit: { dropTarget = nil }, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, + onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .schedule; showDrill = true } ) @@ -106,10 +111,12 @@ struct MatrixView: View { onDropExit: { dropTarget = nil }, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, + onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .delegate_; showDrill = true } ) @@ -125,10 +132,12 @@ struct MatrixView: View { onDropExit: { dropTarget = nil }, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, + onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .eliminate; showDrill = true } ) @@ -155,6 +164,12 @@ struct MatrixView: View { .presentationDetents([.medium]) .presentationDragIndicator(.visible) } + .sheet(item: $editingTask) { task in + TaskEditSheet(task: task) + .environmentObject(taskVM) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .navigationDestination(isPresented: $showDrill) { if let q = drillQuadrant { QuadrantDetailView(quadrant: q) @@ -189,10 +204,12 @@ struct QuadrantCard: View { let onDropExit: () -> Void var onPin: ((TaskItem) -> Void)? = nil var onSetDate: ((TaskItem, Date?) -> Void)? = nil + var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil var onMove: ((TaskItem, Quadrant) -> Void)? = nil var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil var onSetPriority: ((TaskItem, Priority) -> Void)? = nil var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil + var onEdit: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil var onTapHeader: (() -> Void)? = nil @@ -302,11 +319,13 @@ struct QuadrantCard: View { currentQuadrant: quadrant, onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } }, onSetDate: { onSetDate?($0, $1) }, + onPostponeMinutes: { onPostponeMinutes?($0, $1) }, onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } }, onSetPriority: { onSetPriority?($0, $1) }, onSetCategory: { onSetCategory?($0, $1) }, onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onEdit: onEdit.map { cb in { cb($0) } }, onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } } ) } @@ -461,6 +480,7 @@ struct QuadrantDetailView: View { currentQuadrant: quadrant, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -487,6 +507,7 @@ struct QuadrantDetailView: View { currentQuadrant: quadrant, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -514,6 +535,7 @@ struct QuadrantDetailView: View { currentQuadrant: quadrant, onPin: { taskVM.pin($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift index 581e8f5..835036e 100644 --- a/KisaniCal/Views/TaskContextMenu.swift +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -32,6 +32,7 @@ struct TaskMenuItems: View { var onPin: (TaskItem) -> Void = { _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } + var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } @@ -69,8 +70,29 @@ struct TaskMenuItems: View { } label: { Label("Date", systemImage: "calendar.badge.clock") } Menu { - ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in - Button { onMove(task, q) } label: { Text(q.rawValue) } + Button { onPostponeMinutes(task, 15) } label: { + Label("15 Minutes", systemImage: "clock") + } + Button { onPostponeMinutes(task, 30) } label: { + Label("30 Minutes", systemImage: "clock") + } + Button { onPostponeMinutes(task, 60) } label: { + Label("1 Hour", systemImage: "clock") + } + Button { onPostponeMinutes(task, 120) } label: { + Label("2 Hours", systemImage: "clock") + } + Divider() + Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: { + Label("Tomorrow", systemImage: "sun.max") + } + } label: { Label("Postpone", systemImage: "clock.arrow.circlepath") } + + Menu { + ForEach(Quadrant.allCases) { q in + Button { onMove(task, q) } label: { + Label(q.rawValue, systemImage: q == (currentQuadrant ?? task.quadrant) ? "checkmark" : "square") + } } } label: { Label("Move", systemImage: "arrow.right.square") } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index bf6e5e1..a49cd95 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -111,6 +111,7 @@ struct TodayView: View { onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -129,6 +130,7 @@ struct TodayView: View { onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -149,6 +151,7 @@ struct TodayView: View { onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -167,6 +170,7 @@ struct TodayView: View { onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -185,6 +189,7 @@ struct TodayView: View { onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, @@ -540,6 +545,7 @@ struct OverdueCard: View { var onEdit: (TaskItem) -> Void = { _ in } var onDelete: (TaskItem) -> Void = { _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } + var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } @@ -642,6 +648,7 @@ struct OverdueCard: View { task: task, onPin: onPin, onSetDate: onSetDate, + onPostponeMinutes: onPostponeMinutes, onMove: onMove, onSetPriority: onSetPriority, onSetCategory: onSetCategory, @@ -955,6 +962,7 @@ struct UpcomingSection: View { var onEdit: (TaskItem) -> Void = { _ in } let onDelete: (TaskItem) -> Void var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } + var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } @@ -1019,6 +1027,7 @@ struct UpcomingSection: View { task: task, onPin: onPin, onSetDate: onSetDate, + onPostponeMinutes: onPostponeMinutes, onMove: onMove, onSetPriority: onSetPriority, onSetCategory: onSetCategory, @@ -1930,16 +1939,13 @@ struct TaskDatePickerSheet: View { @State private var localTime: Date? @State private var showTimePicker = false @State private var showReminderPicker = false - // Reminder lead-time, expressed as days before the task date (nil = no reminder). + // Reminder lead-time, expressed as MINUTES before the task time (nil = no reminder). @State private var localReminderOffset: Int? - @State private var localReminderTime: Date @State private var localConstant: Bool // Custom wheel state (draft — committed to the reminder only on "Add") @State private var showCustomReminder = false @State private var customNumber: Int - @State private var customUnitWeeks: Bool - @State private var customHour: Int - @State private var customMinute: Int + @State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days @State private var localRepeat: String @State private var localRecurrenceEnd: Date? @State private var showRecurrenceEndPicker = false @@ -1972,30 +1978,24 @@ struct TaskDatePickerSheet: View { _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _localConstant = State(initialValue: constantReminder.wrappedValue) - // Derive lead-time + time-of-day from any existing absolute reminder date. + // Derive the lead-time (minutes before the event time) from any existing reminder. if let r = reminderDate.wrappedValue { - let days = cal.dateComponents([.day], - from: cal.startOfDay(for: r), - to: cal.startOfDay(for: start)).day ?? 0 - let offset = max(0, days) - _localReminderOffset = State(initialValue: offset) - _localReminderTime = State(initialValue: r) - if offset > 0 && offset % 7 == 0 { - _customUnitWeeks = State(initialValue: true) - _customNumber = State(initialValue: offset / 7) + let base: Date = hasTime.wrappedValue + ? (selectedDate.wrappedValue ?? start) + : (cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start) + let mins = max(0, cal.dateComponents([.minute], from: r, to: base).minute ?? 0) + _localReminderOffset = State(initialValue: mins) + if mins > 0 && mins % 1440 == 0 { + _customUnit = State(initialValue: 2); _customNumber = State(initialValue: mins / 1440) + } else if mins > 0 && mins % 60 == 0 { + _customUnit = State(initialValue: 1); _customNumber = State(initialValue: mins / 60) } else { - _customUnitWeeks = State(initialValue: false) - _customNumber = State(initialValue: offset) + _customUnit = State(initialValue: 0); _customNumber = State(initialValue: max(1, mins)) } - _customHour = State(initialValue: cal.component(.hour, from: r)) - _customMinute = State(initialValue: cal.component(.minute, from: r)) } else { _localReminderOffset = State(initialValue: nil) - _localReminderTime = State(initialValue: cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start) - _customUnitWeeks = State(initialValue: false) - _customNumber = State(initialValue: 0) - _customHour = State(initialValue: 9) - _customMinute = State(initialValue: 0) + _customUnit = State(initialValue: 1) // default Custom draft: 1 hour + _customNumber = State(initialValue: 1) } } @@ -2158,14 +2158,15 @@ struct TaskDatePickerSheet: View { let label: String } + // Offsets are MINUTES before the event time. private var reminderPresets: [ReminderPreset] { [ - ReminderPreset(offset: nil, label: "None"), - ReminderPreset(offset: 0, label: "On the day"), - ReminderPreset(offset: 1, label: "1 day early"), - ReminderPreset(offset: 2, label: "2 days early"), - ReminderPreset(offset: 3, label: "3 days early"), - ReminderPreset(offset: 7, label: "1 week early"), + ReminderPreset(offset: nil, label: "None"), + ReminderPreset(offset: 0, label: "On time"), + ReminderPreset(offset: 5, label: "5 minutes early"), + ReminderPreset(offset: 30, label: "30 minutes early"), + ReminderPreset(offset: 60, label: "1 hour early"), + ReminderPreset(offset: 1440, label: "1 day early"), ] } @@ -2176,12 +2177,7 @@ struct TaskDatePickerSheet: View { Button { withAnimation(KisaniSpring.micro) { localReminderOffset = preset.offset - if let off = preset.offset { - if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 } - else { customUnitWeeks = false; customNumber = off } - } else { - localConstant = false - } + if preset.offset == nil { localConstant = false } showCustomReminder = false } } label: { @@ -2189,11 +2185,6 @@ struct TaskDatePickerSheet: View { Text(preset.label) .font(AppFonts.sans(15)) .foregroundColor(preset.offset == nil ? AppColors.accent : .primary) - if preset.offset != nil { - Text("(\(timeFmt.string(from: localReminderTime)))") - .font(AppFonts.sans(13)) - .foregroundColor(.secondary) - } Spacer() if !showCustomReminder && localReminderOffset == preset.offset { Image(systemName: "checkmark") @@ -2242,38 +2233,34 @@ struct TaskDatePickerSheet: View { // MARK: - Custom reminder wheel (Day / Week) + // Minutes-per-unit for the Custom wheel: minutes / hours / days. + private let customUnitMinutes = [1, 60, 1440] + private let customUnitNames = ["minute", "hour", "day"] + private func seedCustomDraft() { - let cal = Calendar.current - if let off = localReminderOffset { - if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 } - else { customUnitWeeks = false; customNumber = off } - } - customHour = cal.component(.hour, from: localReminderTime) - customMinute = cal.component(.minute, from: localReminderTime) + guard let off = localReminderOffset, off > 0 else { return } + if off % 1440 == 0 { customUnit = 2; customNumber = off / 1440 } + else if off % 60 == 0 { customUnit = 1; customNumber = off / 60 } + else { customUnit = 0; customNumber = off } } + private var customOffsetMinutes: Int { max(1, customNumber) * customUnitMinutes[customUnit] } + private func commitCustomReminder() { - let cal = Calendar.current - localReminderOffset = customUnitWeeks ? customNumber * 7 : customNumber - localReminderTime = cal.date(bySettingHour: customHour, minute: customMinute, - second: 0, of: localDate) ?? localReminderTime + localReminderOffset = customOffsetMinutes } - // Label for a given count under the current Day/Week mode. + // Label for a given count under the current unit. private func customLeadLabel(_ n: Int) -> String { - if n == 0 { return "On the day" } - let unit = customUnitWeeks ? "week" : "day" + let unit = customUnitNames[customUnit] return "\(n) \(unit)\(n == 1 ? "" : "s") early" } - private var customMaxCount: Int { customUnitWeeks ? 8 : 60 } + private var customMaxCount: Int { [59, 23, 30][customUnit] } // Reminder date implied by the current *draft* wheel selection. private var draftReminderDate: Date? { - let cal = Calendar.current - let off = customUnitWeeks ? customNumber * 7 : customNumber - let base = cal.date(byAdding: .day, value: -off, to: localDate) ?? localDate - return cal.date(bySettingHour: customHour, minute: customMinute, second: 0, of: base) + Calendar.current.date(byAdding: .minute, value: -customOffsetMinutes, to: reminderBaseDate()) } private var draftReminderInPast: Bool { @@ -2289,42 +2276,30 @@ struct TaskDatePickerSheet: View { .onTapGesture { withAnimation(KisaniSpring.snappy) { showCustomReminder = false } } VStack(spacing: 18) { - // Day / Week segmented toggle + // Minutes / Hours / Days segmented toggle HStack(spacing: 2) { - customModeButton("Day", weeks: false) - customModeButton("Week", weeks: true) + customModeButton("Minutes", unit: 0) + customModeButton("Hours", unit: 1) + customModeButton("Days", unit: 2) } .padding(3) .background(Color(uiColor: .tertiarySystemFill)) .clipShape(Capsule()) .padding(.top, 20) - // Three-column wheel: lead-time · hour · minute - HStack(spacing: 0) { - Picker("", selection: $customNumber) { - ForEach(0...customMaxCount, id: \.self) { n in - Text(customLeadLabel(n)).tag(n) - } + // Single lead-time wheel (relative to the event time) + Picker("", selection: $customNumber) { + ForEach(1...customMaxCount, id: \.self) { n in + Text(customLeadLabel(n)).tag(n) } - .pickerStyle(.wheel).frame(maxWidth: .infinity).clipped() - - Picker("", selection: $customHour) { - ForEach(0...23, id: \.self) { h in - Text(String(format: "%02d", h)).tag(h) - } - } - .pickerStyle(.wheel).frame(width: 64).clipped() - - Picker("", selection: $customMinute) { - ForEach(0...59, id: \.self) { m in - Text(String(format: "%02d", m)).tag(m) - } - } - .pickerStyle(.wheel).frame(width: 64).clipped() } + .pickerStyle(.wheel) + .frame(maxWidth: .infinity) .frame(height: 180) - .onChange(of: customUnitWeeks) { _ in + .clipped() + .onChange(of: customUnit) { _ in if customNumber > customMaxCount { customNumber = customMaxCount } + if customNumber < 1 { customNumber = 1 } } // Subtle (non-red) note — never alarming. @@ -2371,15 +2346,19 @@ struct TaskDatePickerSheet: View { } @ViewBuilder - private func customModeButton(_ label: String, weeks: Bool) -> some View { + private func customModeButton(_ label: String, unit: Int) -> some View { Button { - withAnimation(KisaniSpring.micro) { customUnitWeeks = weeks } + withAnimation(KisaniSpring.micro) { + customUnit = unit + if customNumber > customMaxCount { customNumber = customMaxCount } + if customNumber < 1 { customNumber = 1 } + } } label: { Text(label) - .font(AppFonts.sans(15, weight: customUnitWeeks == weeks ? .semibold : .regular)) - .foregroundColor(customUnitWeeks == weeks ? .primary : .secondary) - .frame(width: 96, height: 36) - .background(customUnitWeeks == weeks ? Color(uiColor: .systemGray4) : Color.clear) + .font(AppFonts.sans(15, weight: customUnit == unit ? .semibold : .regular)) + .foregroundColor(customUnit == unit ? .primary : .secondary) + .frame(maxWidth: .infinity).frame(height: 36) + .background(customUnit == unit ? Color(uiColor: .systemGray4) : Color.clear) .clipShape(Capsule()) } .buttonStyle(.plain) @@ -2390,22 +2369,26 @@ struct TaskDatePickerSheet: View { return computedReminderDate(offset: off) ?? Date() < Date() } - private func computedReminderDate(offset: Int) -> Date? { + // The event datetime the reminder counts back from: the task's time, or 9 AM if undated. + private func reminderBaseDate() -> Date { let cal = Calendar.current - let base = cal.date(byAdding: .day, value: -offset, to: localDate) ?? localDate - let tc = cal.dateComponents([.hour, .minute], from: localReminderTime) - return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: base) + if let t = localTime { + let tc = cal.dateComponents([.hour, .minute], from: t) + return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: localDate) ?? localDate + } + return cal.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate + } + + private func computedReminderDate(offset: Int) -> Date? { + Calendar.current.date(byAdding: .minute, value: -offset, to: reminderBaseDate()) } private var reminderSummary: String { guard let off = localReminderOffset else { return "None" } - let timeStr = timeFmt.string(from: localReminderTime) - if off == 0 { return "On the day · \(timeStr)" } - if off % 7 == 0 { - let w = off / 7 - return "\(w) week\(w == 1 ? "" : "s") early · \(timeStr)" - } - return "\(off) day\(off == 1 ? "" : "s") early · \(timeStr)" + if off == 0 { return "On time" } + if off % 1440 == 0 { let d = off / 1440; return "\(d) day\(d == 1 ? "" : "s") early" } + if off % 60 == 0 { let h = off / 60; return "\(h) hour\(h == 1 ? "" : "s") early" } + return "\(off) minute\(off == 1 ? "" : "s") early" } @ViewBuilder