krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/LaunchPassNotifications.swift

76 lines · 3865 bytes

 1import Feeds
 2import SkyEngine
 3import SwiftUI
 4import UserNotifications
 5
 6/// Opt-in local notifications 15 minutes before a launch's first visible pass. Passes are worked out on the
 7/// device and replaced whenever the launch list is loaded.
 8@MainActor
 9enum LaunchPassNotifications {
10    static let followedKey = "launchPassFollowed"
11    static let lead: TimeInterval = 15 * 60
12
13    static var followed: Set<String> {
14        get { Set(UserDefaults.standard.stringArray(forKey: followedKey) ?? []) }
15        set { UserDefaults.standard.set(Array(newValue), forKey: followedKey) }
16    }
17
18    private static func id(_ launch: Launch) -> String { "launch-pass-\(launch.id)" }
19
20    nonisolated static func passes(of launch: Launch, observer: Observer) async -> [SatellitePass] {
21        let elements = launch.elements ?? []
22        return await Task.detached { SkyEngine.visiblePasses(of: elements, from: .now, observer: observer) }.value
23    }
24
25    static func follow(_ launch: Launch) async -> Bool {
26        let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
27        guard granted else { return false }
28        followed.insert(launch.id)
29        await schedule(launch, observer: libraryObserver())
30        return true
31    }
32
33    static func unfollow(_ launch: Launch) {
34        followed.remove(launch.id)
35        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id(launch)])
36    }
37
38    /// Replaces the notifications for followed launches in `launches`; followed launches no longer listed are dropped.
39    static func reschedule(_ launches: [Launch]) async {
40        let listed = Set(launches.map(\.id))
41        let center = UNUserNotificationCenter.current()
42        let stale = followed.subtracting(listed)
43        center.removePendingNotificationRequests(withIdentifiers: stale.map { "launch-pass-\($0)" })
44        followed.formIntersection(listed)
45        let observer = libraryObserver()
46        for launch in launches where followed.contains(launch.id) {
47            await schedule(launch, observer: observer)
48        }
49    }
50
51    /// Loads the launch list, when anything is followed, and reschedules.
52    static func refresh() async {
53        guard !followed.isEmpty, let apiBase else { return }
54        var list: LaunchList? = FeedMemo.get("launches", maxAge: 300)
55        if list == nil { list = try? await LaunchFeed.fetch(base: apiBase) }
56        guard let list else { return }
57        await reschedule(list.launches + (list.recent ?? []))
58    }
59
60    private static func schedule(_ launch: Launch, observer: Observer) async {
61        let center = UNUserNotificationCenter.current()
62        center.removePendingNotificationRequests(withIdentifiers: [id(launch)])
63        guard let pass = await passes(of: launch, observer: observer).first(where: { $0.rise.addingTimeInterval(-lead) > .now }) else { return }
64        let time = pass.rise.formatted(date: .omitted, time: .shortened)
65        let content = UNMutableNotificationContent()
66        content.title = "\(launch.mission ?? launch.rocket): first visible pass at \(time)"
67        content.body = "\(pass.displayName) rises in the \(compass(pass.riseAzimuth)), highest \(Int(pass.maxAltitude.rounded()))°, sets in the \(compass(pass.setAzimuth)). Sunlit while your sky is dark."
68        content.sound = .default
69        let parts = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: pass.rise.addingTimeInterval(-lead))
70        try? await center.add(UNNotificationRequest(identifier: id(launch), content: content, trigger: UNCalendarNotificationTrigger(dateMatching: parts, repeats: false)))
71    }
72
73    static func compass(_ azimuth: Double) -> String {
74        ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
75    }
76}