From 1d4d7f07c58680059ad4135e7bcd9f66df784e6a Mon Sep 17 00:00:00 2001 From: kutesir Date: Fri, 29 May 2026 10:33:55 +0300 Subject: [PATCH] Add task persistence and per-task reminder notifications Tasks now survive app restarts via UserDefaults JSON. Each task with a due time fires a notification at that exact time; date-only tasks fire at 9am on the due date. Completing or deleting a task cancels its pending notification. ContentView now reschedules on any task change. --- KisaniCal/ContentView.swift | 6 +- KisaniCal/Managers/NotificationManager.swift | 74 ++++++++++++++++---- KisaniCal/Models/TaskItem.swift | 44 +++++++++--- KisaniCal/Views/TodayView.swift | 2 +- 4 files changed, 97 insertions(+), 29 deletions(-) diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 7001d7a..059a0f2 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -86,11 +86,11 @@ struct ContentView: View { workoutVM.syncWidget() } } - .onChange(of: taskVM.tasks.count) { _ in taskVM.syncWidget() } - .onChange(of: workoutVM.doneSets) { _ in + .onChange(of: taskVM.tasks) { _ in + taskVM.syncWidget() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) } - .onChange(of: taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count) { _ in + .onChange(of: workoutVM.doneSets) { _ in NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) } } diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 495132c..ccf1972 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -81,27 +81,71 @@ final class NotificationManager: NSObject, ObservableObject { } } - // MARK: - Urgent tasks (7:30 PM daily, only when there are open urgent tasks) + // MARK: - Per-task reminders + evening check-in private func scheduleTasks(_ taskVM: TaskViewModel) { - center.removePendingNotificationRequests(withIdentifiers: [tasksId]) + // Fetch pending so we can cleanly remove all previous task notifications + center.getPendingNotificationRequests { [weak self] pending in + guard let self else { return } - let count = taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count - guard count > 0 else { return } + let oldTaskIds = pending + .filter { $0.identifier.hasPrefix("kisani.task.") } + .map { $0.identifier } + self.center.removePendingNotificationRequests(withIdentifiers: oldTaskIds) + self.center.removePendingNotificationRequests(withIdentifiers: [self.tasksId]) - let content = UNMutableNotificationContent() - content.title = "Evening task check-in" - content.body = "\(count) urgent task\(count == 1 ? "" : "s") still open — mark done or reschedule." - content.sound = .default - content.userInfo = ["deeplink": "tasks"] + let now = Date() + let cal = Calendar.current - var comps = DateComponents() - comps.hour = 19 - comps.minute = 30 + for task in taskVM.tasks where !task.isComplete { + guard let due = task.dueDate else { continue } - let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) - let request = UNNotificationRequest(identifier: tasksId, content: content, trigger: trigger) - center.add(request) + // Determine fire time: exact if user specified a time, else 9:00 AM on due date + let fireDate: Date + if task.hasTime { + fireDate = due + } else { + var c = cal.dateComponents([.year, .month, .day], from: due) + c.hour = 9; c.minute = 0 + fireDate = cal.date(from: c) ?? due + } + guard fireDate > now else { continue } + + let content = UNMutableNotificationContent() + content.title = task.title + content.body = self.notifBody(for: task) + content.sound = .default + content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"] + + let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) + let trigger = UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) + self.center.add(UNNotificationRequest( + identifier: "kisani.task.\(task.id.uuidString)", + content: content, trigger: trigger + )) + } + + // Evening urgent check-in + let count = taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count + guard count > 0 else { return } + let ec = UNMutableNotificationContent() + ec.title = "Evening check-in" + ec.body = "\(count) urgent task\(count == 1 ? "" : "s") still open." + ec.sound = .default + ec.userInfo = ["deeplink": "tasks"] + var eComps = DateComponents(); eComps.hour = 19; eComps.minute = 30 + let eTrigger = UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true) + self.center.add(UNNotificationRequest(identifier: self.tasksId, content: ec, trigger: eTrigger)) + } + } + + private func notifBody(for task: TaskItem) -> String { + switch task.category { + case .birthday: return "Birthday today" + case .domain: return "Domain renewal coming up" + case .annual: return "Annual reminder" + default: return task.quadrant == .urgent ? "Urgent — tap to mark complete" : "Task reminder" + } } } diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 9561cf0..f49fbac 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -1,10 +1,11 @@ import SwiftUI // MARK: - Task Model -struct TaskItem: Identifiable { +struct TaskItem: Identifiable, Codable, Equatable { var id = UUID() var title: String var dueDate: Date? + var hasTime: Bool = false var isComplete: Bool = false var completedAt: Date? = nil var quadrant: Quadrant = .urgent @@ -13,7 +14,7 @@ struct TaskItem: Identifiable { var isRecurring: Bool = false } -enum Quadrant: String, CaseIterable, Identifiable { +enum Quadrant: String, CaseIterable, Identifiable, Codable { var id: String { rawValue } case urgent = "Urgent + Important" case schedule = "Schedule It" @@ -21,7 +22,7 @@ enum Quadrant: String, CaseIterable, Identifiable { case eliminate = "Eliminate" } -enum TaskCategory: String { +enum TaskCategory: String, Codable { case reminder = "Reminder" case birthday = "Birthday" case domain = "Domain" @@ -29,7 +30,7 @@ enum TaskCategory: String { case custom = "Custom" } -enum TaskColor { +enum TaskColor: String, Codable { case orange, green, blue, yellow func color() -> Color { @@ -52,7 +53,24 @@ enum TaskColor { // MARK: - Task View Model class TaskViewModel: ObservableObject { - @Published var tasks: [TaskItem] = sampleTasks + @Published var tasks: [TaskItem] + + private static let persistKey = "kisani.tasks.v2" + + init() { + if let data = UserDefaults.standard.data(forKey: Self.persistKey), + let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty { + tasks = saved + } else { + tasks = sampleTasks + } + } + + private func save() { + if let data = try? JSONEncoder().encode(tasks) { + UserDefaults.standard.set(data, forKey: Self.persistKey) + } + } var overdueTasks: [TaskItem] { let startOfDay = Calendar.current.startOfDay(for: Date()) @@ -83,22 +101,26 @@ class TaskViewModel: ObservableObject { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[idx].isComplete.toggle() tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil + save() } func tasks(for quadrant: Quadrant) -> [TaskItem] { tasks.filter { $0.quadrant == quadrant } } - func addTask(title: String, dueDate: Date?, quadrant: Quadrant = .urgent, - category: TaskCategory = .reminder, taskColor: TaskColor = .orange, - isRecurring: Bool = false) { - tasks.append(TaskItem(title: title, dueDate: dueDate, quadrant: quadrant, - category: category, taskColor: taskColor, isRecurring: isRecurring)) + func addTask(title: String, dueDate: Date?, hasTime: Bool = false, + quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, + taskColor: TaskColor = .orange, isRecurring: Bool = false) { + tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, + quadrant: quadrant, category: category, + taskColor: taskColor, isRecurring: isRecurring)) + save() } func postpone(_ task: TaskItem, days: Int = 1) { 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) + save() } func postponeAllOverdue(days: Int = 1) { @@ -108,10 +130,12 @@ class TaskViewModel: ObservableObject { let base = tasks[idx].dueDate ?? Date() tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base) } + save() } func delete(_ task: TaskItem) { tasks.removeAll { $0.id == task.id } + save() } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index ffbf462..30af472 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -1096,7 +1096,7 @@ struct AddTaskSheet: View { private func submit() { guard canAdd else { return } - taskVM.addTask(title: cleanTitle, dueDate: parsed.date, + taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, quadrant: selectedQuadrant, category: selectedCategory) dismiss() }