krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/ReminderScheduler.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/OrgApp/ReminderScheduler.swift history · blame · raw

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