feat: 添加自定义提醒功能并优化项目配置

- 添加 CustomReminder 模型支持自由文案周期性提醒功能
- 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示
- 集成本地通知服务支持自定义提醒的时间触发
- 更新项目配置文件添加应用显示名称和加密声明
- 修正 iOS 部署目标版本从 26.0 到 17.0
- 修复 FileDownloader 中的线程安全问题
- 优化 ModelManifest 和 Localization 的并发安全性
- 扩展本地化字符串支持多语言提醒相关文本
- 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator
```
This commit is contained in:
link2026
2026-05-30 11:36:29 +08:00
parent d2c77d5c51
commit dad9d43486
12 changed files with 861 additions and 84 deletions

View File

@@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.lock()
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
lock.unlock()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
}
var request = URLRequest(url: url)
if offset > 0 {

View File

@@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable {
/// , README.md / .gitattributes()
/// ,
/// docs/superpowers/specs/2026-05-29-model-download-design.md A
enum ModelManifest {
nonisolated enum ModelManifest {
/// Caddy ( HTTPS )
/// IP( App ATS ): http://101.132.124.52:5244/
static let baseURL = URL(string: "https://file.myv0.com/")!

View File

@@ -17,6 +17,7 @@ struct KangkangApp: App {
MetricReminder.self,
CustomMonitorMetric.self,
HealthExport.self,
CustomReminder.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {

View File

@@ -80,16 +80,24 @@ final class LanguageManager {
lprojBundle = .main
}
Bundle.redirectMain(to: current.localeIdentifier)
// nonisolated , String(appLoc:) MainActor
appLocBundle = lprojBundle
appLocLocale = resolvedLocale
}
}
/// nonisolated :`String(appLoc:)` MainActor
/// (LocalizedError.errorDescriptionnonisolated labelstatic )
/// `LanguageManager.apply()`(MainActor),;,
nonisolated(unsafe) private var appLocBundle: Bundle = .main
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
extension String {
/// · ()
/// `String(localized:)`, bundle + locale,
/// `Locale.current`(/)
init(appLoc key: String.LocalizationValue) {
let m = LanguageManager.shared
self = String(localized: key, bundle: m.lprojBundle, locale: m.resolvedLocale)
nonisolated init(appLoc key: String.LocalizationValue) {
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
}
}

View File

@@ -0,0 +1,186 @@
import SwiftUI
import SwiftData
/// /
/// `reminder == nil` ;()
/// @State , SwiftData + ;
struct CustomReminderEditSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil =
let reminder: CustomReminder?
@State private var title = ""
@State private var note = ""
@State private var pickedTime: Date = .now
@State private var weekdays: Set<Int> = Set(1...7)
@State private var hydrated = false
@State private var showAuthDeniedAlert = false
init(reminder: CustomReminder? = nil) {
self.reminder = reminder
}
private var isEditing: Bool { reminder != nil }
private var trimmedTitle: String {
title.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool { !trimmedTitle.isEmpty && !weekdays.isEmpty }
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(appLoc: "做点什么?例:跑步5公里 / 吃2片护肝片"),
text: $title, axis: .vertical)
.lineLimit(1...3)
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
.lineLimit(1...3)
.foregroundStyle(Tj.Palette.text2)
}
Section {
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
displayedComponents: .hourAndMinute)
}
Section {
weekdayRow
} header: {
Text("重复")
}
if isEditing {
Section {
Button(role: .destructive) { deleteReminder() } label: {
Label(String(appLoc: "删除提醒"), systemImage: "trash")
}
}
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(isEditing ? String(appLoc: "编辑提醒") : String(appLoc: "新建提醒"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
.onAppear(perform: hydrate)
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
Button(String(appLoc: "")) { dismiss() }
} message: {
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
}
}
}
// MARK: - ( RemindersListView )
private var weekdayRow: some View {
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let values = [2, 3, 4, 5, 6, 7, 1]
return HStack(spacing: 6) {
ForEach(Array(values.enumerated()), id: \.offset) { idx, w in
let on = weekdays.contains(w)
Button {
if on { weekdays.remove(w) } else { weekdays.insert(w) }
} label: {
Text(names[idx])
.font(.system(size: 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
// MARK: -
private func hydrate() {
guard !hydrated else { return }
hydrated = true
if let r = reminder {
title = r.title
note = r.note
weekdays = Set(r.weekdays)
pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now
}
}
private func save() {
guard canSave else { return }
let cal = Calendar.current
let hour = cal.component(.hour, from: pickedTime)
let minute = cal.component(.minute, from: pickedTime)
let sortedDays = weekdays.sorted()
let target: CustomReminder
if let r = reminder {
r.title = trimmedTitle
r.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
r.hour = hour
r.minute = minute
r.weekdays = sortedDays
r.updatedAt = .now
target = r
} else {
let new = CustomReminder(
title: trimmedTitle,
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
hour: hour,
minute: minute,
weekdays: sortedDays
)
ctx.insert(new)
target = new
}
try? ctx.save()
Task { @MainActor in
let state = await ReminderService.requestAuthorization()
await ReminderService.sync(target)
if state == .denied {
showAuthDeniedAlert = true
} else {
dismiss()
}
}
}
private func deleteReminder() {
guard let r = reminder else { return }
ReminderService.cancel(customId: r.id)
ctx.delete(r)
try? ctx.save()
dismiss()
}
}
#Preview("新建") {
CustomReminderEditSheet()
.modelContainer(for: [CustomReminder.self], inMemory: true)
}

View File

@@ -4,31 +4,50 @@ import SwiftData
struct RemindersListView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var reminders: [MetricReminder]
/// sheet ();
/// push ( ), false
/// push , false
var presentedAsSheet = false
@State private var editingId: String?
@State private var creatingNew = false
@State private var editingCustom: CustomReminder?
private var isEmpty: Bool { customReminders.isEmpty && reminders.isEmpty }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
header
if reminders.isEmpty {
createButton
if isEmpty {
emptyState
} else {
ForEach(reminders) { r in
ReminderRow(
ForEach(customReminders) { r in
CustomReminderRow(
reminder: r,
isEditing: editingId == r.metricId,
onTapEdit: { toggleEdit(r.metricId) },
onChange: { Task { await sync(r) } },
onDelete: { delete(r) }
onTapEdit: { editingCustom = r },
onToggle: { Task { await syncCustom(r) } }
)
}
if !reminders.isEmpty {
sectionLabel(String(appLoc: "指标记录提醒"))
ForEach(reminders) { r in
ReminderRow(
reminder: r,
isEditing: editingId == r.metricId,
onTapEdit: { toggleEdit(r.metricId) },
onChange: { Task { await sync(r) } },
onDelete: { delete(r) }
)
}
}
}
}
.padding(.horizontal, 16)
@@ -36,40 +55,65 @@ struct RemindersListView: View {
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("记录提醒")
.navigationTitle("提醒")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if presentedAsSheet {
ToolbarItem(placement: .topBarTrailing) {
Button("完成") { dismiss() }
Button(String(appLoc: "完成")) { dismiss() }
}
}
}
.sheet(isPresented: $creatingNew) {
CustomReminderEditSheet()
}
.sheet(item: $editingCustom) { r in
CustomReminderEditSheet(reminder: r)
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(enabledCount) / \(reminders.count) 项启用")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("提醒在录入「指标记录 · 长期监测」时开启")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("新建提醒,或在记录指标时开启")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var createButton: some View {
Button { creatingNew = true } label: {
Label(String(appLoc: "新建提醒"), systemImage: "plus")
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(TjPrimaryButton(height: 46, fontSize: 14))
}
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 40)
TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开"))
TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
private var enabledCount: Int { reminders.filter(\.enabled).count }
// MARK: -
private func syncCustom(_ r: CustomReminder) async {
r.updatedAt = .now
try? ctx.save()
await ReminderService.sync(r)
}
// MARK: - (沿)
private func toggleEdit(_ id: String) {
editingId = (editingId == id) ? nil : id
@@ -88,6 +132,61 @@ struct RemindersListView: View {
}
}
/// : sheet; Toggle
private struct CustomReminderRow: View {
@Bindable var reminder: CustomReminder
let onTapEdit: () -> Void
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Button(action: onTapEdit) {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminder.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Toggle("", isOn: $reminder.enabled)
.labelsHidden()
.tint(Tj.Palette.ink)
.onChange(of: reminder.enabled) { _, _ in onToggle() }
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}
private struct ReminderRow: View {
@Bindable var reminder: MetricReminder
let isEditing: Bool
@@ -233,5 +332,5 @@ private struct ReminderRow: View {
NavigationStack {
RemindersListView()
}
.modelContainer(for: [MetricReminder.self], inMemory: true)
.modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true)
}

View File

@@ -11588,7 +11588,271 @@
}
}
}
},
"做点什么?例:跑步5公里 / 吃2片护肝片": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "What to do? e.g. Run 5 km / Take 2 pills"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "何をしますか?例:5km走る / 薬を2錠飲む"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "무엇을 하나요? 예: 5km 달리기 / 약 2알 복용"
}
}
}
},
"重复": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Repeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "繰り返し"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "반복"
}
}
}
},
"编辑提醒": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Edit Reminder"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リマインダーを編集"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "알림 편집"
}
}
}
},
"新建提醒": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New Reminder"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新しいリマインダー"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "새 알림"
}
}
}
},
"通知未开启": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Notifications Off"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知がオフです"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "알림이 꺼져 있음"
}
}
}
},
"好": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "확인"
}
}
}
},
"提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The reminder is saved, but notifications are off so it won't alert you. Allow them in Settings · Notifications · Kangkang."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リマインダーは保存されましたが、通知が許可されていないため表示されません。「設定 · 通知 · 康康」で許可してください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "알림이 저장되었지만 시스템 알림 권한이 꺼져 있어 표시되지 않습니다. '설정 · 알림 · 康康'에서 허용하세요."
}
}
}
},
"提醒": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reminders"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リマインダー"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "알림"
}
}
}
},
"指标记录提醒": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Metric Reminders"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "指標リマインダー"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "지표 알림"
}
}
}
},
"新建提醒,或在记录指标时开启": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Create a reminder, or enable one when logging a metric."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リマインダーを作成、または指標の記録時に有効化。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "알림을 만들거나 지표 기록 시 설정하세요."
}
}
}
},
"还没有提醒,点上方新建": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No reminders yet. Tap + above to add one."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リマインダーはまだありません。上の+で追加。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "아직 알림이 없습니다. 위의 +로 추가하세요."
}
}
}
},
"到点啦,记得完成": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Time's up — don't forget!"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "時間です。お忘れなく!"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "시간이 되었어요. 잊지 마세요!"
}
}
}
}
},
"version": "1.0"
}
}

View File

@@ -263,6 +263,58 @@ final class MetricReminder {
}
}
/// ( 20:00 5 12:30 2 )
/// `MetricReminder`():,
/// (5 / 2 ) `title`
/// 沿 weekday ( 7 = ); `ReminderService`
@Model
final class CustomReminder {
@Attribute(.unique) var id: UUID
var title: String // , "5"
var note: String //
var hour: Int // 0...23
var minute: Int // 0...59
var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 =
var enabled: Bool
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(),
title: String,
note: String = "",
hour: Int = 8,
minute: Int = 0,
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
enabled: Bool = true,
createdAt: Date = .now) {
self.id = id
self.title = title
self.note = note
self.hour = max(0, min(23, hour))
self.minute = max(0, min(59, minute))
self.weekdays = weekdays
self.enabled = enabled
self.createdAt = createdAt
self.updatedAt = createdAt
}
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
/// MetricReminder.frequencyLabel , key
var frequencyLabel: String {
if !enabled { return String(appLoc: "已关闭") }
if isEveryDay { return String(appLoc: "每天") }
if weekdays.isEmpty { return String(appLoc: "未选日") }
let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")]
let sorted = weekdays.sorted()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
var timeLabel: String {
String(format: "%02d:%02d", hour, minute)
}
}
@Model
final class ChatTurn {
var question: String

View File

@@ -93,8 +93,9 @@ final class ModelDownloadService {
to: destination,
expectedBytes: file.bytes,
onProgress: { [weak self] received in
guard let self else { return }
Task { @MainActor in
self?.applyProgress(kind, currentTotal: base + received)
self.applyProgress(kind, currentTotal: base + received)
}
}
)

View File

@@ -10,6 +10,7 @@ import UserNotifications
enum ReminderService {
static let idPrefix = "kangkang.reminder."
static let customIdPrefix = "kangkang.custom."
enum AuthState: String {
case granted, denied, notDetermined, provisional
@@ -51,34 +52,45 @@ enum ReminderService {
/// `MetricReminder` save
static func sync(_ reminder: MetricReminder) async {
cancel(metricId: reminder.metricId)
guard reminder.enabled, !reminder.weekdays.isEmpty else { return }
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = String(appLoc: "该测\(reminder.displayName)")
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
content.sound = .default
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
for weekday in reminder.weekdays {
var comps = DateComponents()
comps.hour = reminder.hour
comps.minute = reminder.minute
comps.weekday = weekday
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let id = identifier(metricId: reminder.metricId, weekday: weekday)
let request = UNNotificationRequest(identifier: id,
content: content,
trigger: trigger)
try? await center.add(request)
}
guard reminder.enabled else { return }
await schedule(
idBase: "\(idPrefix)\(reminder.metricId)",
title: String(appLoc: "该测\(reminder.displayName)"),
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
hour: reminder.hour,
minute: reminder.minute,
weekdays: reminder.weekdays,
thread: "kangkang.reminder.\(reminder.metricId)"
)
}
/// metric pending (7 weekday ,)
static func cancel(metricId: String) {
let center = UNUserNotificationCenter.current()
let ids = (1...7).map { identifier(metricId: metricId, weekday: $0) }
center.removePendingNotificationRequests(withIdentifiers: ids)
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)
await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
title: title.isEmpty ? String(appLoc: "提醒") : title,
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
hour: reminder.hour,
minute: reminder.minute,
weekdays: reminder.weekdays,
thread: "\(customIdPrefix)\(reminder.id.uuidString)"
)
}
/// pending
static func cancel(customId: UUID) {
cancelBase("\(customIdPrefix)\(customId.uuidString)")
}
/// Me Tab
@@ -86,9 +98,42 @@ enum ReminderService {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
// MARK: - helpers
// MARK: -
private static func identifier(metricId: String, weekday: Int) -> String {
"\(idPrefix)\(metricId).w\(weekday)"
/// weekdays N weekly-repeats
/// `idBase` `.w<weekday>` ;
private static func schedule(idBase: String,
title: String,
body: String,
hour: Int,
minute: Int,
weekdays: [Int],
thread: String) async {
guard !weekdays.isEmpty else { return }
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.threadIdentifier = thread
for weekday in weekdays {
var comps = DateComponents()
comps.hour = hour
comps.minute = minute
comps.weekday = weekday
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(identifier: "\(idBase).w\(weekday)",
content: content,
trigger: trigger)
try? await center.add(request)
}
}
/// idBase 7 weekday pending ()
private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current()
let ids = (1...7).map { "\(idBase).w\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids)
}
}