krz/orgstar

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

Sources/OrgApp/ReminderScheduler.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgApp/ReminderScheduler.swift history · blame · raw

108 lines · 4473 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    /// 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