Sources/OrgApp/ReminderScheduler.swift
95 lines · 3757 bytes
1import Foundation
2import Observation
3import OrgCore
4#if canImport(UserNotifications)
5import UserNotifications
6#endif
7
8/// Where reminders are delivered: the system's notification center, or a fake in tests.
9public protocol ReminderCenter: Sendable {
10 /// Whether notifications may be shown, asking the user the first time.
11 func authorize() async -> Bool
12 /// IDs of pending requests.
13 func pendingIDs() async -> [String]
14 func add(_ reminder: Reminder) async throws
15 func remove(_ ids: [String])
16}
17
18/// Keeps the pending notifications equal to the reminders the agenda gives.
19@MainActor
20@Observable
21public final class ReminderScheduler {
22 /// The last event a reminder is set for, so the agenda can say how far ahead they go.
23 public private(set) var scheduledThrough: Date?
24 /// False when the user turned notifications off for the app.
25 public private(set) var isAuthorized = true
26
27 private let center: ReminderCenter
28
29 public init(center: ReminderCenter) {
30 self.center = center
31 }
32
33 /// Adds reminders that are new, removes ours that no longer apply, leaves the rest.
34 public func reconcile(_ reminders: [Reminder]) async {
35 isAuthorized = await center.authorize()
36 guard isAuthorized else {
37 scheduledThrough = nil
38 return
39 }
40 let wanted = Set(reminders.map(\.id))
41 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
42 let stale = pending.subtracting(wanted)
43 if !stale.isEmpty { center.remove(Array(stale)) }
44 for reminder in reminders where !pending.contains(reminder.id) {
45 try? await center.add(reminder)
46 }
47 scheduledThrough = reminders.map(\.eventDate).max()
48 }
49
50 /// Removes every reminder, when the user turns them off.
51 public func clear() async {
52 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
53 if !ours.isEmpty { center.remove(ours) }
54 scheduledThrough = nil
55 }
56}
57
58#if canImport(UserNotifications)
59/// `UNUserNotificationCenter`. Only usable from an app bundle.
60public struct SystemReminderCenter: ReminderCenter {
61 public init() {}
62
63 /// The system center needs a bundle identifier; a bare debug binary has none.
64 public static var isAvailable: Bool { Bundle.main.bundleIdentifier != nil }
65
66 public func authorize() async -> Bool {
67 let center = UNUserNotificationCenter.current()
68 let settings = await center.notificationSettings()
69 switch settings.authorizationStatus {
70 case .authorized, .provisional: return true
71 case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
72 default: return false
73 }
74 }
75
76 public func pendingIDs() async -> [String] {
77 await UNUserNotificationCenter.current().pendingNotificationRequests().map(\.identifier)
78 }
79
80 public func add(_ reminder: Reminder) async throws {
81 let content = UNMutableNotificationContent()
82 content.title = reminder.title
83 content.body = reminder.body
84 content.sound = .default
85 content.userInfo = ["path": reminder.path, "offset": reminder.offset]
86 let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: reminder.fireDate)
87 let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
88 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
89 }
90
91 public func remove(_ ids: [String]) {
92 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
93 }
94}
95#endif