186 lines
6.0 KiB
Swift
186 lines
6.0 KiB
Swift
// ── 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 nil }
|
|
|
|
if urgency == .critical && !isWarning {
|
|
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])
|
|
}
|
|
}
|