// ── Notification Scheduling ──────────────────────────────────── // UNUserNotificationCenter wrapper for pre-warning cascade + timer fire // iOS-native notification system import Foundation import UserNotifications // MARK: - Notification Manager @MainActor final class CMNotificationManager: ObservableObject { static let shared = CMNotificationManager() @Published var isAuthorized = false private init() {} // MARK: - Permission func requestPermission() async { do { let granted = try await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .badge, .criticalAlert]) isAuthorized = granted } catch { isAuthorized = false } } func checkPermission() async { let settings = await UNUserNotificationCenter.current().notificationSettings() isAuthorized = settings.authorizationStatus == .authorized } // MARK: - Schedule Timer Notifications /// Schedule all cascade warnings + final fire notification for a timer func scheduleNotifications(for timer: CMTimer) { // Remove existing notifications for this timer removeNotifications(for: timer.id) // Schedule cascade warnings for warning in timer.warnings where !warning.fired { scheduleWarning(timer: timer, warning: warning) } // Schedule fire notification scheduleFireNotification(timer: timer) } /// Remove all scheduled notifications for a timer func removeNotifications(for timerId: String) { let center = UNUserNotificationCenter.current() // Remove all notifications with this timer's ID prefix center.getPendingNotificationRequests { requests in let ids = requests .filter { $0.identifier.hasPrefix("cm-\(timerId)") } .map { $0.identifier } center.removePendingNotificationRequests(withIdentifiers: ids) } } /// Remove all ChronoMind notifications func removeAllNotifications() { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } // MARK: - Private private func scheduleWarning(timer: CMTimer, warning: CascadeWarning) { let content = UNMutableNotificationContent() let timeStr = formatMinutesBefore(warning.minutesBefore) content.title = "⏰ \(timer.label) in \(timeStr)" content.body = "Pre-warning: \"\(timer.label)\" fires in \(timeStr)" content.sound = soundForUrgency(timer.urgency, isWarning: true) content.categoryIdentifier = "TIMER_WARNING" content.userInfo = [ "timerId": timer.id, "warningId": warning.id, "type": "warning", ] // Use the warning's scheduled time let trigger = UNTimeIntervalNotificationTrigger( timeInterval: max(1, warning.scheduledTime.timeIntervalSinceNow), repeats: false ) let request = UNNotificationRequest( identifier: "cm-\(timer.id)-warning-\(warning.id)", content: content, trigger: trigger ) UNUserNotificationCenter.current().add(request) } private func scheduleFireNotification(timer: CMTimer) { let content = UNMutableNotificationContent() content.title = "🔔 \(timer.label) — NOW!" content.body = "Timer \"\(timer.label)\" is firing!" content.sound = soundForUrgency(timer.urgency, isWarning: false) content.categoryIdentifier = "TIMER_FIRE" content.userInfo = [ "timerId": timer.id, "type": "fire", ] // Interrupt level for important/critical if #available(iOS 15.0, *) { switch timer.urgency { case .critical: content.interruptionLevel = .critical case .important: content.interruptionLevel = .timeSensitive case .standard: content.interruptionLevel = .active case .gentle: content.interruptionLevel = .passive case .passive: content.interruptionLevel = .passive } } let trigger = UNTimeIntervalNotificationTrigger( timeInterval: max(1, timer.targetTime.timeIntervalSinceNow), repeats: false ) let request = UNNotificationRequest( identifier: "cm-\(timer.id)-fire", content: content, trigger: trigger ) UNUserNotificationCenter.current().add(request) } private func soundForUrgency(_ urgency: UrgencyLevel, isWarning: Bool) -> UNNotificationSound { let config = getUrgencyConfig(urgency) guard config.soundEnabled else { return .none } if urgency == .critical && !isWarning { // Critical fires get the default critical alert sound if #available(iOS 12.0, *) { return .defaultCritical } } return .default } // MARK: - Notification Categories func registerCategories() { let snooze5 = UNNotificationAction( identifier: "SNOOZE_5", title: "Snooze 5m", options: [] ) let snooze15 = UNNotificationAction( identifier: "SNOOZE_15", title: "Snooze 15m", options: [] ) let dismiss = UNNotificationAction( identifier: "DISMISS", title: "Dismiss", options: [.destructive] ) let fireCategory = UNNotificationCategory( identifier: "TIMER_FIRE", actions: [snooze5, snooze15, dismiss], intentIdentifiers: [], options: [] ) let warningCategory = UNNotificationCategory( identifier: "TIMER_WARNING", actions: [dismiss], intentIdentifiers: [], options: [] ) UNUserNotificationCenter.current().setNotificationCategories([fireCategory, warningCategory]) } }