docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
157 lines
6.5 KiB
Swift
157 lines
6.5 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
|
|
/// 周期性指标提醒的本地通知调度。
|
|
/// 同一 `metricId` 在 iOS 通知中心展开成 N 条 weekly-repeats 通知,id 形如
|
|
/// `kangkang.reminder.<metricId>.w<weekday>`,方便按 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(`<idBase>.<suffix>`,
|
|
/// 如 `.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)
|
|
}
|
|
}
|