App/Sources/LaunchPassNotifications.swift
76 lines · 3865 bytes
9 symbols in this file
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}