import SwiftUI import UserNotifications import WidgetKit final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable { static let shared = NotificationManager() @Published var isAuthorized = false private let center = UNUserNotificationCenter.current() private let workoutPrefix = "kisani.workout.weekday" private let checkInPrefix = "kisani.workout.checkin" private let confirmPrefix = "kisani.workout.confirm" private let tasksId = "kisani.tasks.evening" 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" private static let snoozeWorkoutActId = "SNOOZE_WORKOUT" override private init() { super.init() center.delegate = self registerCategories() refreshAuthStatus() } private func registerCategories() { let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: []) let taskSnoozeActions = [ UNNotificationAction(identifier: Self.taskSnoozePrefix + "15", title: "Snooze 15 min", options: []), UNNotificationAction(identifier: Self.taskSnoozePrefix + "30", title: "Snooze 30 min", options: []), UNNotificationAction(identifier: Self.taskSnoozePrefix + "60", title: "Snooze 1 hour", options: []), UNNotificationAction(identifier: Self.taskSnoozePrefix + "120", title: "Snooze 2 hours", options: []) ] let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction] + taskSnoozeActions, intentIdentifiers: [], options: .customDismissAction) let logAction = UNNotificationAction(identifier: Self.logWorkoutActId, title: "Yes, I completed it", options: []) let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId, title: "No, I didn't", options: []) let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId, title: "Remind me in 1 hour", options: []) let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction, missedAction, snoozeAction], intentIdentifiers: [], options: .customDismissAction) center.setNotificationCategories([taskCat, confirmCat]) } // MARK: - Complete task directly in UserDefaults (called from background) private func markTaskComplete(taskId: String, userId: String) { let key = "kisani.tasks.v2.\(userId)" guard 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 }) else { return } tasks[idx].isComplete = true tasks[idx].completedAt = Date() if let encoded = try? JSONEncoder().encode(tasks) { UserDefaults.kisani.set(encoded, forKey: key) CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch WidgetCenter.shared.reloadAllTimelines() } } 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) CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch WidgetCenter.shared.reloadAllTimelines() } } 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() { center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in DispatchQueue.main.async { self?.isAuthorized = granted } } } private func refreshAuthStatus() { center.getNotificationSettings { [weak self] settings in DispatchQueue.main.async { self?.isAuthorized = (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional) } } } // MARK: - Reschedule (call on foreground + state changes) @MainActor func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) { // Snapshot all @MainActor-isolated values before the async boundary let schedule = workoutVM.schedule let programs = workoutVM.programs let progress = workoutVM.progress let recorded = workoutVM.isTodayRecorded let tasks = taskVM.tasks // Live urgency (computed Matrix quadrant), not the stored fallback, so notification // copy reflects tasks that have become urgent as their dates approached. let urgentIds = Set(tasks.filter { taskVM.displayQuadrant($0) == .urgent }.map { $0.id }) let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7) let compensations = workoutVM.compensations center.getNotificationSettings { [weak self] settings in guard let self else { return } let ok = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional guard ok else { return } self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded) self.scheduleWorkoutConfirm(schedule: schedule, programs: programs) self.scheduleCompensations(compensations, programs: programs) self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds) self.scheduleCalendarEvents(snapshot: calEvents) self.schedulePeriodMilestones() self.updateBadge(snapshot: tasks) } } // MARK: - Period milestones ("one more week / month / year passed") private static let periodIds = ["kisani.period.day", "kisani.period.week", "kisani.period.month", "kisani.period.year"] /// Recurring nudges at the close of each week, month, and year. Fixed identifiers /// + remove-then-add keep them de-duplicated across reschedules. Disabled (and /// cleared) when the user turns off the "Period milestones" setting. private func schedulePeriodMilestones() { center.removePendingNotificationRequests(withIdentifiers: Self.periodIds) guard UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return } func add(_ id: String, _ title: String, _ body: String, _ comps: DateComponents) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default center.add(UNNotificationRequest( identifier: id, content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true))) } // Every midnight → a new day began var day = DateComponents(); day.hour = 0; day.minute = 0 add(Self.periodIds[0], "Today", "One more day passed.", day) // Start of the week (Mon 00:00) → the week that just ended var week = DateComponents(); week.weekday = 2; week.hour = 0; week.minute = 0 add(Self.periodIds[1], "This Week", "One more week passed.", week) // 1st of the month, 00:00 → the month that just ended var month = DateComponents(); month.day = 1; month.hour = 0; month.minute = 0 add(Self.periodIds[2], "This Month", "One more month passed.", month) // Jan 1, 00:00 → the year that just ended var year = DateComponents(); year.month = 1; year.day = 1; year.hour = 0; year.minute = 0 add(Self.periodIds[3], "This Year", "One more year passed.", year) } // MARK: - Recurring task completion confirmation /// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's /// occurrence is completed, telling the user when it next comes around. func notifyRecurringCompleted(title: String, nextOccurrence: Date?) { center.getNotificationSettings { [weak self] settings in guard let self, settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return } let content = UNMutableNotificationContent() content.title = title content.body = nextOccurrence.map { "Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "Done." content.sound = nil // quiet confirmation, not an alert self.center.add(UNNotificationRequest( identifier: "kisani.recurdone.\(UUID().uuidString)", content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false))) } } /// "tomorrow", "in 1 week", "in 3 days" … relative to today. static func relativeRepeat(to date: Date) -> String { let cal = Calendar.current let days = cal.dateComponents([.day], from: cal.startOfDay(for: Date()), to: cal.startOfDay(for: date)).day ?? 0 switch days { case ..<1: return "again today" case 1: return "tomorrow" case 2...6: return "in \(days) days" case 7: return "in 1 week" default: if days % 7 == 0 { let w = days / 7; return "in \(w) week\(w == 1 ? "" : "s")" } if days % 30 == 0 { let m = days / 30; return "in \(m) month\(m == 1 ? "" : "s")" } return "in \(days) days" } } // MARK: - Compensation workout reminders (one-off, on chosen rest days) private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) { center.getPendingNotificationRequests { [weak self] pending in guard let self else { return } let old = pending.filter { $0.identifier.hasPrefix("kisani.workout.comp.") }.map { $0.identifier } self.center.removePendingNotificationRequests(withIdentifiers: old) let ud = UserDefaults.kisani let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18 let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0 let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" let cal = Calendar.current for (dateStr, pidStr) in comps { guard let day = fmt.date(from: dateStr), day > Date().addingTimeInterval(-86400), let pid = UUID(uuidString: pidStr), let program = programs.first(where: { $0.id == pid }) else { continue } var comps = cal.dateComponents([.year, .month, .day], from: day) comps.hour = rHour; comps.minute = rMinute let content = UNMutableNotificationContent() content.title = "\(program.name) — Compensation" content.body = "Missed workout recovery — time to make it up!" content.sound = .default content.categoryIdentifier = Self.confirmCatId content.userInfo = ["deeplink": "workout"] self.center.add(UNNotificationRequest( identifier: "kisani.workout.comp.\(dateStr)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false) )) } } } // MARK: - Calendar event notifications /// Notifies for visible calendar events: at start time ("on time") plus each /// alarm the user set on the event. Capped to stay under iOS's 64-notification /// limit. Recurring instances stay unique via their start time in the id. private func scheduleCalendarEvents(snapshot: [CalEventNotif]) { center.getPendingNotificationRequests { [weak self] pending in guard let self else { return } let old = pending.filter { $0.identifier.hasPrefix("kisani.calevent.") }.map { $0.identifier } self.center.removePendingNotificationRequests(withIdentifiers: old) let now = Date() let cal = Calendar.current var budget = 20 // leave headroom under the 64 pending-notification ceiling for ev in snapshot where budget > 0 { var fireTimes: [Date] = [] if ev.isAllDay { var c = cal.dateComponents([.year, .month, .day], from: ev.start) c.hour = 9; c.minute = 0 if let d = cal.date(from: c) { fireTimes.append(d) } // 9am morning-of } else { fireTimes.append(ev.start) // on time for off in ev.alarmOffsets { fireTimes.append(ev.start.addingTimeInterval(off)) } } let times = Array(Set(fireTimes)).filter { $0 > now }.sorted() for (i, t) in times.enumerated() where budget > 0 { let content = UNMutableNotificationContent() content.title = ev.title content.body = self.calEventBody(fire: t, start: ev.start, isAllDay: ev.isAllDay) content.sound = .default content.userInfo = ["deeplink": "calendar"] let comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: t) let stamp = Int(ev.start.timeIntervalSince1970) self.center.add(UNNotificationRequest( identifier: "kisani.calevent.\(ev.id).\(stamp).\(i)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false) )) budget -= 1 } } } } private func calEventBody(fire: Date, start: Date, isAllDay: Bool) -> String { if isAllDay { return "Today" } let delta = start.timeIntervalSince(fire) if delta <= 60 { return "Starting now" } let mins = Int((delta / 60).rounded()) if mins < 60 { return "Starts in \(mins) min" } let hrs = mins / 60 return "Starts in \(hrs)h\(mins % 60 == 0 ? "" : " \(mins % 60)m")" } // MARK: - Badge private func updateBadge(snapshot: [TaskItem]) { let startOfDay = Calendar.current.startOfDay(for: Date()) let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)! let count = snapshot.filter { t in guard !t.isComplete, let due = t.dueDate else { return false } return due < tomorrow // overdue or due today }.count center.setBadgeCount(count) { _ in } } // MARK: - Workout reminders + check-in private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double, recordedToday: Bool) { let ud = UserDefaults.kisani let markCompleteId = "kisani.workout.markcomplete" let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } + [markCompleteId] center.removePendingNotificationRequests(withIdentifiers: existingIds) let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true let checkInOn = ud.object(forKey: "workoutCheckInEnabled") as? Bool ?? false let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18 let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0 let cHour = ud.object(forKey: "checkInHour") as? Int ?? 19 let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30 let todayWD = Calendar.current.component(.weekday, from: Date()) // In-app completion vs. "recorded" (in-app OR detected from Apple Health). let inAppDoneToday = progress >= 1.0 for (weekday, pid) in schedule { guard let program = programs.first(where: { $0.id == pid }) else { continue } // Suppress "time to work out" if today is already done by either path. let doneToday = weekday == todayWD && (inAppDoneToday || recordedToday) if reminderOn && !doneToday { let content = UNMutableNotificationContent() content.title = program.name content.body = "Time to work out — let's go!" content.sound = .default content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze content.userInfo = ["deeplink": "workout"] var comps = DateComponents() comps.weekday = weekday; comps.hour = rHour; comps.minute = rMinute center.add(UNNotificationRequest( identifier: "\(workoutPrefix).\(weekday)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) )) } if checkInOn { let content = UNMutableNotificationContent() content.title = "\(program.name) — check in" content.body = doneToday ? "Great work today!" : "Have you done this workout?" content.sound = .default content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze content.userInfo = ["deeplink": "workout"] var comps = DateComponents() comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute center.add(UNNotificationRequest( identifier: "\(checkInPrefix).\(weekday)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) )) } } // Mark-complete nudge: Apple Health recorded a workout today (streak is safe), // but the user hasn't marked their exercises complete in the app yet. if recordedToday && !inAppDoneToday { let cal = Calendar.current var fireComps = cal.dateComponents([.year, .month, .day], from: Date()) fireComps.hour = rHour; fireComps.minute = rMinute var fire = cal.date(from: fireComps) ?? Date() if fire <= Date() { fire = Date().addingTimeInterval(3600) } // reminder time passed → nudge in 1h let content = UNMutableNotificationContent() content.title = "Mark your workout complete" content.body = "We logged today's workout from Apple Health — mark your exercises complete to update your log." content.sound = .default content.userInfo = ["deeplink": "workout"] let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fire) center.add(UNNotificationRequest( identifier: markCompleteId, content: content, trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) )) } } // MARK: - Workout completion confirmation private func scheduleWorkoutConfirm(schedule: [Int: UUID], programs: [WorkoutProgram]) { let ud = UserDefaults.kisani guard ud.object(forKey: "workoutConfirmEnabled") as? Bool ?? false else { center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" }) return } let cHour = ud.object(forKey: "workoutConfirmHour") as? Int ?? 20 let cMinute = ud.object(forKey: "workoutConfirmMinute") as? Int ?? 0 center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" }) for (weekday, pid) in schedule { guard let program = programs.first(where: { $0.id == pid }) else { continue } let content = UNMutableNotificationContent() content.title = "Did you work out today?" content.body = "\(program.name) — tap 'Yes, log it' to record your streak, or dismiss if you skipped." content.sound = .default content.categoryIdentifier = Self.confirmCatId content.userInfo = ["deeplink": "workout"] var comps = DateComponents() comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute center.add(UNNotificationRequest( identifier: "\(confirmPrefix).\(weekday)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) )) } } // MARK: - Per-task reminders + evening check-in private func scheduleTasks(snapshot: [TaskItem], urgentIds: Set) { let center = self.center center.getPendingNotificationRequests { [weak self] pending in guard let self else { return } let oldTaskIds = pending .filter { $0.identifier.hasPrefix("kisani.task.") } .map { $0.identifier } center.removePendingNotificationRequests(withIdentifiers: oldTaskIds) center.removePendingNotificationRequests(withIdentifiers: [self.tasksId]) let now = Date() let cal = Calendar.current for task in snapshot where !task.isComplete { // reminderDate wins if set; otherwise fall back to due time or 9am let fireDate: Date if let reminder = task.reminderDate { fireDate = reminder } else if let due = task.dueDate { 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 } } else { continue } // A one-off reminder in the past is dropped; a constant (daily-repeating) // reminder still schedules so it keeps nudging on its next occurrence. guard task.constantReminder || fireDate > now else { continue } let content = UNMutableNotificationContent() content.title = task.title content.body = self.notifBody(for: task, isUrgent: urgentIds.contains(task.id)) content.sound = .default content.categoryIdentifier = Self.categoryId content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"] // Constant reminders re-fire daily (at the reminder time) until the // task is completed; one-off reminders match the full date. let trigComps = task.constantReminder ? cal.dateComponents([.hour, .minute], from: fireDate) : cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) center.add(UNNotificationRequest( identifier: "kisani.task.\(task.id.uuidString)", content: content, trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: task.constantReminder) )) } // No count in body — repeating notifications bake in the value // at schedule time and go stale until the app is next opened. guard snapshot.contains(where: { !$0.isComplete && urgentIds.contains($0.id) }) else { return } let ec = UNMutableNotificationContent() ec.title = "Evening check-in" ec.body = "You still have urgent tasks open — tap to review." ec.sound = .default ec.userInfo = ["deeplink": "tasks"] var eComps = DateComponents(); eComps.hour = 19; eComps.minute = 30 center.add(UNNotificationRequest( identifier: self.tasksId, content: ec, trigger: UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true) )) } } private func notifBody(for task: TaskItem, isUrgent: Bool) -> String { switch task.category { case .birthday: return "Birthday today" case .domain: return "Domain renewal coming up" case .annual: return "Annual reminder" default: return isUrgent ? "Urgent — tap to mark complete" : "Task reminder" } } } // MARK: - UNUserNotificationCenterDelegate extension NotificationManager: UNUserNotificationCenterDelegate { nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .sound, .list, .badge]) } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userId = UserDefaults.kisani.string(forKey: AuthManager.activeUserIdKey) ?? "shared" let info = response.notification.request.content.userInfo let deeplink = info["deeplink"] as? String let taskId = info["taskId"] as? String switch response.actionIdentifier { 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" UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm") case Self.missedWorkoutActId: // "No, I didn't" — mark the day missed; the app asks about compensation on next open. let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingMissed") case Self.snoozeWorkoutActId: // Re-deliver this same check-in in one hour. let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent center.add(UNNotificationRequest( identifier: "kisani.workout.snooze.\(UUID().uuidString)", content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false) )) default: if let taskId { markTaskComplete(taskId: taskId, userId: userId) } Task { @MainActor in switch deeplink { case "workout": ShortcutHandler.shared.handle(QuickAction.workout.rawValue) default: ShortcutHandler.shared.handle(QuickAction.today.rawValue) } } } completionHandler() } }