import Foundation import UserNotifications /// 周期性指标提醒的本地通知调度。 /// 同一 `metricId` 在 iOS 通知中心展开成 N 条 weekly-repeats 通知,id 形如 /// `kangkang.reminder..w`,方便按 weekday 单独 cancel。 /// /// 数据存 SwiftData `MetricReminder`;本服务只负责系统通知中心的同步, /// 不写 SwiftData。两边写入的协调由调用方负责。 enum ReminderService { static let idPrefix = "kangkang.reminder." static let customIdPrefix = "kangkang.custom." enum AuthState: String { case granted, denied, notDetermined, provisional } // MARK: - authorization static func currentAuthState() async -> AuthState { let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized: return .granted case .denied: return .denied case .provisional: return .provisional case .ephemeral: return .granted case .notDetermined: return .notDetermined @unknown default: return .notDetermined } } /// 申请通知权限。已 granted/denied 时直接返回当前状态。 @discardableResult static func requestAuthorization() async -> AuthState { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() if settings.authorizationStatus != .notDetermined { return await currentAuthState() } do { let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) return granted ? .granted : .denied } catch { return .denied } } // MARK: - upsert / cancel /// 取消该 metric 在通知中心所有 pending 通知,再按当前 enabled/时间/weekdays 重排。 /// 调用方在 `MetricReminder` save 之后调用。 static func sync(_ reminder: MetricReminder) async { cancel(metricId: reminder.metricId) guard reminder.enabled else { return } let slots = reminder.weekdays.map { wd in Slot(suffix: "w\(wd)", dc: DateComponents(hour: reminder.hour, minute: reminder.minute, weekday: wd)) } await schedule( idBase: "\(idPrefix)\(reminder.metricId)", title: String(appLoc: "该测\(reminder.displayName)了"), body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"), thread: "kangkang.reminder.\(reminder.metricId)", slots: slots ) } /// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。 static func cancel(metricId: String) { cancelBase("\(idPrefix)\(metricId)") } // MARK: - 自由提醒(CustomReminder) /// 取消并按当前设置重排一条自由提醒。调用方在 `CustomReminder` save 之后调用。 static func sync(_ reminder: CustomReminder) async { cancel(customId: reminder.id) guard reminder.enabled else { return } let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) let h = reminder.hour, m = reminder.minute let slots: [Slot] switch reminder.frequency { case .daily: slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))] case .weekly: slots = reminder.weekdays.map { wd in Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) } case .monthly: slots = [Slot(suffix: "monthly", dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))] case .yearly: slots = [Slot(suffix: "yearly", dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))] } await schedule( idBase: "\(customIdPrefix)\(reminder.id.uuidString)", title: title.isEmpty ? String(appLoc: "提醒") : title, body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body, thread: "\(customIdPrefix)\(reminder.id.uuidString)", slots: slots ) } /// 取消某条自由提醒的所有 pending 通知。 static func cancel(customId: UUID) { cancelBase("\(customIdPrefix)\(customId.uuidString)") } /// 全清。Me Tab 一键关闭所有提醒时用。 static func cancelAll() { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } // MARK: - 共享调度核心 /// 一条触发槽:`suffix` 用于拼出稳定且可单独取消的通知 id(`.`, /// 如 `.daily` / `.w2` / `.monthly` / `.yearly`),`dc` 为对应的重复触发时间分量。 private struct Slot { let suffix: String let dc: DateComponents } /// 把若干 `Slot` 展开成 N 条 repeats 通知。每日/每周/每月/每年两类提醒共用本核心。 private static func schedule(idBase: String, title: String, body: String, thread: String, slots: [Slot]) async { guard !slots.isEmpty else { return } let center = UNUserNotificationCenter.current() let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.threadIdentifier = thread for slot in slots { let trigger = UNCalendarNotificationTrigger(dateMatching: slot.dc, repeats: true) let request = UNNotificationRequest(identifier: "\(idBase).\(slot.suffix)", content: content, trigger: trigger) try? await center.add(request) } } /// 取消某个 idBase 下所有可能后缀的 pending 通知(daily/monthly/yearly + 7 个 weekday,不漏)。 private static func cancelBase(_ idBase: String) { let center = UNUserNotificationCenter.current() var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"] ids += (1...7).map { "\(idBase).w\($0)" } center.removePendingNotificationRequests(withIdentifiers: ids) } }