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.
This commit is contained in:
kutesir
2026-05-29 10:33:55 +03:00
parent bc55d27f45
commit 1d4d7f07c5
4 changed files with 97 additions and 29 deletions

View File

@@ -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"
}
}
}