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:
@@ -86,11 +86,11 @@ struct ContentView: View {
|
|||||||
workoutVM.syncWidget()
|
workoutVM.syncWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: taskVM.tasks.count) { _ in taskVM.syncWidget() }
|
.onChange(of: taskVM.tasks) { _ in
|
||||||
.onChange(of: workoutVM.doneSets) { _ in
|
taskVM.syncWidget()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
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)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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
|
let oldTaskIds = pending
|
||||||
guard count > 0 else { return }
|
.filter { $0.identifier.hasPrefix("kisani.task.") }
|
||||||
|
.map { $0.identifier }
|
||||||
|
self.center.removePendingNotificationRequests(withIdentifiers: oldTaskIds)
|
||||||
|
self.center.removePendingNotificationRequests(withIdentifiers: [self.tasksId])
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let now = Date()
|
||||||
content.title = "Evening task check-in"
|
let cal = Calendar.current
|
||||||
content.body = "\(count) urgent task\(count == 1 ? "" : "s") still open — mark done or reschedule."
|
|
||||||
content.sound = .default
|
|
||||||
content.userInfo = ["deeplink": "tasks"]
|
|
||||||
|
|
||||||
var comps = DateComponents()
|
for task in taskVM.tasks where !task.isComplete {
|
||||||
comps.hour = 19
|
guard let due = task.dueDate else { continue }
|
||||||
comps.minute = 30
|
|
||||||
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
// Determine fire time: exact if user specified a time, else 9:00 AM on due date
|
||||||
let request = UNNotificationRequest(identifier: tasksId, content: content, trigger: trigger)
|
let fireDate: Date
|
||||||
center.add(request)
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Task Model
|
// MARK: - Task Model
|
||||||
struct TaskItem: Identifiable {
|
struct TaskItem: Identifiable, Codable, Equatable {
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
var title: String
|
var title: String
|
||||||
var dueDate: Date?
|
var dueDate: Date?
|
||||||
|
var hasTime: Bool = false
|
||||||
var isComplete: Bool = false
|
var isComplete: Bool = false
|
||||||
var completedAt: Date? = nil
|
var completedAt: Date? = nil
|
||||||
var quadrant: Quadrant = .urgent
|
var quadrant: Quadrant = .urgent
|
||||||
@@ -13,7 +14,7 @@ struct TaskItem: Identifiable {
|
|||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Quadrant: String, CaseIterable, Identifiable {
|
enum Quadrant: String, CaseIterable, Identifiable, Codable {
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
case urgent = "Urgent + Important"
|
case urgent = "Urgent + Important"
|
||||||
case schedule = "Schedule It"
|
case schedule = "Schedule It"
|
||||||
@@ -21,7 +22,7 @@ enum Quadrant: String, CaseIterable, Identifiable {
|
|||||||
case eliminate = "Eliminate"
|
case eliminate = "Eliminate"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskCategory: String {
|
enum TaskCategory: String, Codable {
|
||||||
case reminder = "Reminder"
|
case reminder = "Reminder"
|
||||||
case birthday = "Birthday"
|
case birthday = "Birthday"
|
||||||
case domain = "Domain"
|
case domain = "Domain"
|
||||||
@@ -29,7 +30,7 @@ enum TaskCategory: String {
|
|||||||
case custom = "Custom"
|
case custom = "Custom"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskColor {
|
enum TaskColor: String, Codable {
|
||||||
case orange, green, blue, yellow
|
case orange, green, blue, yellow
|
||||||
|
|
||||||
func color() -> Color {
|
func color() -> Color {
|
||||||
@@ -52,7 +53,24 @@ enum TaskColor {
|
|||||||
|
|
||||||
// MARK: - Task View Model
|
// MARK: - Task View Model
|
||||||
class TaskViewModel: ObservableObject {
|
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] {
|
var overdueTasks: [TaskItem] {
|
||||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
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 }
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
tasks[idx].isComplete.toggle()
|
tasks[idx].isComplete.toggle()
|
||||||
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
func tasks(for quadrant: Quadrant) -> [TaskItem] {
|
func tasks(for quadrant: Quadrant) -> [TaskItem] {
|
||||||
tasks.filter { $0.quadrant == quadrant }
|
tasks.filter { $0.quadrant == quadrant }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addTask(title: String, dueDate: Date?, quadrant: Quadrant = .urgent,
|
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
||||||
category: TaskCategory = .reminder, taskColor: TaskColor = .orange,
|
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
||||||
isRecurring: Bool = false) {
|
taskColor: TaskColor = .orange, isRecurring: Bool = false) {
|
||||||
tasks.append(TaskItem(title: title, dueDate: dueDate, quadrant: quadrant,
|
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||||
category: category, taskColor: taskColor, isRecurring: isRecurring))
|
quadrant: quadrant, category: category,
|
||||||
|
taskColor: taskColor, isRecurring: isRecurring))
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func postpone(_ task: TaskItem, days: Int = 1) {
|
func postpone(_ task: TaskItem, days: Int = 1) {
|
||||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
let base = tasks[idx].dueDate ?? Date()
|
let base = tasks[idx].dueDate ?? Date()
|
||||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func postponeAllOverdue(days: Int = 1) {
|
func postponeAllOverdue(days: Int = 1) {
|
||||||
@@ -108,10 +130,12 @@ class TaskViewModel: ObservableObject {
|
|||||||
let base = tasks[idx].dueDate ?? Date()
|
let base = tasks[idx].dueDate ?? Date()
|
||||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||||
}
|
}
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ task: TaskItem) {
|
func delete(_ task: TaskItem) {
|
||||||
tasks.removeAll { $0.id == task.id }
|
tasks.removeAll { $0.id == task.id }
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1096,7 +1096,7 @@ struct AddTaskSheet: View {
|
|||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
guard canAdd else { return }
|
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)
|
quadrant: selectedQuadrant, category: selectedCategory)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user