Files
KisaniCal/KisaniCal/Managers/NotificationManager.swift
kutesir cb0dc3e271 Add Apple Watch app (TickTick-style) + midnight period notifications
WenzaWatch (new watchOS target, embed disabled until the watchOS platform is
installed — see project.yml note):
- Today list of actionable tasks (dated today/overdue + recurring occurrences),
  tap-to-complete with haptics, and add-via-dictation.
- Reads/writes the same tasks through the shared iCloud KV store that
  CloudSyncManager mirrors on the phone; watch entitlement uses the iOS app's
  kvstore identifier so they share one store. New tasks include all required
  TaskItem keys so the phone decodes them.
- NOTE: not compiled here (watchOS SDK absent on this machine); iOS build
  verified green with the embed commented out.

Period notifications: day/week/month/year now all fire at midnight (00:00),
adding the daily "One more day passed." (Pretty Progress style).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:50:37 +03:00

572 lines
28 KiB
Swift

import SwiftUI
import UserNotifications
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)
}
}
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)
}
}
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
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)
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]) {
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)
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 && $0.quadrant == .urgent }) 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) -> 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"
}
}
}
// 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()
}
}