580 lines
28 KiB
Swift
580 lines
28 KiB
Swift
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<UUID>) {
|
|
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()
|
|
}
|
|
}
|