Sources/OrgApp/ReminderScheduler.swift
108 lines · 4473 bytes
15 symbols in this file
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 /// IDs of notifications shown and still in Notification Center.
15 func deliveredIDs() async -> [String]
16 func add(_ reminder: Reminder) async throws
17 func remove(_ ids: [String])
18}
19
20/// Keeps the pending notifications equal to the reminders the agenda gives.
21@MainActor
22@Observable
23public final class ReminderScheduler {
24 /// The last event a reminder is set for, so the agenda can say how far ahead they go.
25 public private(set) var scheduledThrough: Date?
26 /// False when the user turned notifications off for the app.
27 public private(set) var isAuthorized = true
28
29 private let center: ReminderCenter
30 /// What this run set; one no longer pending has fired and isn't set again.
31 private var scheduled: Set<String> = []
32
33 public init(center: ReminderCenter) {
34 self.center = center
35 }
36
37 /// Adds reminders that are new, removes ours that no longer apply, leaves the rest.
38 public func reconcile(_ reminders: [Reminder]) async {
39 isAuthorized = await center.authorize()
40 guard isAuthorized else {
41 scheduledThrough = nil
42 return
43 }
44 let wanted = Set(reminders.map(\.id))
45 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
46 let stale = pending.subtracting(wanted)
47 if !stale.isEmpty { center.remove(Array(stale)) }
48 scheduled.subtract(stale)
49 // A reminder whose warning time has passed fires at once; once shown, it stays shown.
50 let delivered = Set(await center.deliveredIDs())
51 for reminder in reminders where !pending.contains(reminder.id) && !scheduled.contains(reminder.id) && !delivered.contains(reminder.id) {
52 try? await center.add(reminder)
53 scheduled.insert(reminder.id)
54 }
55 scheduledThrough = reminders.map(\.eventDate).max()
56 }
57
58 /// Removes every reminder, when the user turns them off.
59 public func clear() async {
60 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
61 if !ours.isEmpty { center.remove(ours) }
62 scheduled = []
63 scheduledThrough = nil
64 }
65}
66
67#if canImport(UserNotifications)
68/// `UNUserNotificationCenter`. Only usable from an app bundle.
69public struct SystemReminderCenter: ReminderCenter {
70 public init() {}
71
72 /// The system center needs a bundle identifier; a bare debug binary has none.
73 public static var isAvailable: Bool { Bundle.main.bundleIdentifier != nil }
74
75 public func authorize() async -> Bool {
76 let center = UNUserNotificationCenter.current()
77 let settings = await center.notificationSettings()
78 switch settings.authorizationStatus {
79 case .authorized, .provisional: return true
80 case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
81 default: return false
82 }
83 }
84
85 public func pendingIDs() async -> [String] {
86 await UNUserNotificationCenter.current().pendingNotificationRequests().map(\.identifier)
87 }
88
89 public func add(_ reminder: Reminder) async throws {
90 let content = UNMutableNotificationContent()
91 content.title = reminder.title
92 content.body = reminder.body
93 content.sound = .default
94 content.userInfo = ["path": reminder.path, "offset": reminder.offset]
95 let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: reminder.fireDate)
96 let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
97 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
98 }
99
100 public func deliveredIDs() async -> [String] {
101 await UNUserNotificationCenter.current().deliveredNotifications().map(\.request.identifier)
102 }
103
104 public func remove(_ ids: [String]) {
105 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
106 }
107}
108#endif