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/LaunchesScreen.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Sources/LaunchesScreen.swift history · blame · raw

335 lines · 14877 bytes

  1import ActivityKit
  2import Feeds
  3import Library
  4import SkyEngine
  5import SwiftUI
  6
  7/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
  8let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String)
  9    .flatMap { $0.isEmpty ? nil : URL(string: $0) }
 10
 11/// Tab and News/Launches switch that a notification tap can change, and what Siri or Spotlight asked to show.
 12@MainActor
 13@Observable
 14final class Router {
 15    static let shared = Router()
 16    var tab = "sky"
 17    var showNews = false
 18    /// Presented as a sheet over any tab.
 19    var libraryPage: LibraryPage?
 20    /// Typed into the Library's search field.
 21    var libraryQuery: String?
 22    /// Turned to by the Sky tab once its scene exists.
 23    var skyTarget: Tour.Target?
 24}
 25
 26struct LaunchesScreen: View {
 27    @State private var router = Router.shared
 28    @State private var list: LaunchList?
 29    @State private var error: String?
 30    @State private var selected: Launch?
 31    @State private var newsReload = 0
 32
 33    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 34    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 35    private let background = Color.black
 36
 37    var body: some View {
 38        ScrollView {
 39            VStack(alignment: .leading, spacing: 0) {
 40                Text("News & launches").font(.title.weight(.medium))
 41                Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
 42                Picker("Show", selection: $router.showNews) {
 43                    Text("Launches").tag(false)
 44                    Text("News").tag(true)
 45                }
 46                .pickerStyle(.segmented)
 47                .padding(.bottom, 8)
 48
 49                if router.showNews {
 50                    NewsList(ink: ink, accent: accent, reload: newsReload)
 51                } else {
 52                    launches
 53                }
 54            }
 55            .padding(.horizontal, 20)
 56            .readableWidth()
 57        }
 58        .foregroundStyle(ink)
 59        .background(background.ignoresSafeArea())
 60        .refreshable { if router.showNews { newsReload += 1 } else { await load(force: true) } }
 61        .task { await load() }
 62        .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
 63    }
 64
 65    @ViewBuilder
 66    private var launches: some View {
 67                if let error {
 68                    Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
 69                }
 70                if let list {
 71                    ForEach(Array(list.launches.enumerated()), id: \.element.id) { i, launch in
 72                        Button { selected = launch } label: { row(launch, index: i + 1) }
 73                            .buttonStyle(.plain)
 74                    }
 75                    if let recent = list.recent, !recent.isEmpty {
 76                        Text("Recent").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 20).padding(.bottom, 4)
 77                        ForEach(Array(recent.enumerated()), id: \.element.id) { i, launch in
 78                            Button { selected = launch } label: { row(launch, index: i + 1) }
 79                                .buttonStyle(.plain)
 80                        }
 81                    }
 82                    Text("Launch data: The Space Devs, Launch Library 2.")
 83                        .font(.caption2).dimmed(0.5).padding(.vertical, 16)
 84                } else if error == nil {
 85                    ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
 86                }
 87    }
 88
 89    /// The backend polls every 10 minutes; a visit within 5 minutes of the last load reuses it.
 90    private func load(force: Bool = false) async {
 91        guard let apiBase else { error = "No backend configured for this build."; return }
 92        if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
 93        do {
 94            list = try await LaunchFeed.fetch(base: apiBase)
 95            if let list {
 96                FeedMemo.set("launches", list)
 97                await LaunchPassNotifications.reschedule(list.launches + (list.recent ?? []))
 98            }
 99            error = nil
100        } catch {
101            self.error = "Couldn't load launches. Pull to retry."
102        }
103    }
104
105    private func row(_ l: Launch, index: Int) -> some View {
106        VStack(alignment: .leading, spacing: 6) {
107            HStack(alignment: .firstTextBaseline) {
108                Text(String(format: "%02d", index)).font(.caption2.monospacedDigit()).dimmed(0.5)
109                Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
110                    .font(.caption2).dimmed(0.7)
111                Spacer()
112                StatusTag(status: l.status, ink: ink, accent: accent)
113            }
114            Text(l.rocket).font(.title2.weight(.medium))
115            if let m = l.mission { Text(m).font(.subheadline) }
116            HStack {
117                Text("\(l.provider) · \(l.location)").font(.caption2).dimmed(0.7)
118                Spacer()
119                Countdown(to: l.net).font(.footnote.weight(.medium).monospacedDigit())
120            }
121        }
122        .padding(.vertical, 14)
123        .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
124        .contentShape(Rectangle())
125    }
126}
127
128struct StatusTag: View {
129    var status: String
130    var ink: Color
131    var accent: Color
132
133    var body: some View {
134        Text(status.uppercased())
135            .font(.caption2.weight(.medium))
136            .tracking(0.6)
137            .padding(.horizontal, 6).padding(.vertical, 2)
138            .foregroundStyle(status == "Go" ? Color.black : ink)
139            .background(status == "Go" ? ink : (status == "Hold" ? accent : ink.opacity(0.15)), in: .rect(cornerRadius: 2))
140    }
141}
142
143/// T− countdown, ticking each second; T+ once the time has passed.
144struct Countdown: View {
145    var to: Date
146
147    var body: some View {
148        TimelineView(.periodic(from: .now, by: 1)) { ctx in
149            Text(Self.format(to.timeIntervalSince(ctx.date)))
150        }
151    }
152
153    static func format(_ seconds: TimeInterval) -> String {
154        let s = Int(abs(seconds))
155        let sign = seconds >= 0 ? "T−" : "T+"
156        let d = s / 86_400, h = (s % 86_400) / 3600, m = (s % 3600) / 60, sec = s % 60
157        return d > 0 ? String(format: "%@%dd %02d:%02d:%02d", sign, d, h, m, sec) : String(format: "%@%02d:%02d:%02d", sign, h, m, sec)
158    }
159}
160
161struct LaunchDetail: View {
162    var launch: Launch
163    var ink: Color
164    var accent: Color
165    var background: Color
166
167    var body: some View {
168        ScrollView {
169            VStack(alignment: .leading, spacing: 10) {
170                HStack {
171                    StatusTag(status: launch.status, ink: ink, accent: accent)
172                    Text(launch.statusName).font(.caption).dimmed(0.7)
173                }
174                Text(launch.rocket).font(.title.weight(.medium))
175                if let m = launch.mission { Text(m).font(.body) }
176                Countdown(to: launch.net).font(.largeTitle.weight(.medium).monospacedDigit()).padding(.vertical, 6)
177                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
178                    fact("Provider", launch.provider)
179                    fact("Launch time", launch.net.formatted(date: .abbreviated, time: .shortened))
180                    if let a = launch.windowStart, let b = launch.windowEnd, a != b {
181                        fact("Window", "\(a.formatted(date: .omitted, time: .shortened))–\(b.formatted(date: .omitted, time: .shortened))")
182                    }
183                    if let t = launch.missionType { fact("Mission", t) }
184                    if let o = launch.orbit { fact("Orbit", o) }
185                    fact("Pad", launch.pad)
186                    fact("Location", launch.location)
187                }
188                .font(.subheadline)
189                visibility.padding(.top, 8)
190                followButton.padding(.top, 8)
191                if let message { Text(message).font(.caption).dimmed(0.7) }
192                if let d = launch.missionDescription {
193                    Text(d).font(.subheadline).dimmed(0.85).padding(.top, 8)
194                }
195            }
196            .padding(20)
197            .frame(maxWidth: .infinity, alignment: .leading)
198        }
199        .foregroundStyle(ink)
200        .presentationBackground(background)
201        .presentationDetents([.medium, .large])
202    }
203
204    @State private var message: String?
205    @State private var following = false
206    @State private var passes: [SatellitePass]?
207    @State private var notifying = false
208    @State private var notifyMessage: String?
209
210    /// First visible passes over the observer, worked out on the device.
211    @ViewBuilder
212    private var visibility: some View {
213        if let elements = launch.elements, !elements.isEmpty {
214            VStack(alignment: .leading, spacing: 6) {
215                Text("Visibility").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
216                if let passes {
217                    if let first = passes.first {
218                        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
219                            fact("First visible pass", describe(first))
220                            ForEach(passes.filter { $0.name == first.name }.dropFirst().prefix(2)) { fact("Later", describe($0)) }
221                        }
222                        .font(.subheadline)
223                        Text("\(first.displayName). Sunlit while your sky is dark; it may still be too faint to see\(elements.contains { $0.OBJECT_NAME.hasPrefix("Starlink train") } ? ", though new trains are often visible" : "").")
224                            .font(.caption).dimmed(0.7)
225                        Text(source).font(.caption2).dimmed(0.5)
226                        notifyButton
227                        if let notifyMessage { Text(notifyMessage).font(.caption).dimmed(0.7) }
228                    } else {
229                        Text("No visible pass from here in the next three days.").font(.subheadline).dimmed(0.7)
230                    }
231                } else {
232                    ProgressView().tint(ink)
233                }
234            }
235            .task {
236                notifying = LaunchPassNotifications.followed.contains(launch.id)
237                passes = await LaunchPassNotifications.passes(of: launch, observer: libraryObserver())
238            }
239        } else if launch.net < .now {
240            Text("No orbit published for this launch yet.").font(.caption).dimmed(0.7)
241        }
242    }
243
244    private var source: String {
245        let credit = launch.elementsSource == "supgp" ? "SpaceX via CelesTrak" : "CelesTrak"
246        return launch.elementsSource == "supgp" && launch.net > .now ? "Predicted before launch · \(credit)" : "Orbit: \(credit)"
247    }
248
249    private func describe(_ p: SatellitePass) -> String {
250        p.rise.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
251            + " · \(LaunchPassNotifications.compass(p.riseAzimuth)) → \(LaunchPassNotifications.compass(p.setAzimuth)), highest \(Int(p.maxAltitude.rounded()))°"
252    }
253
254    private var notifyButton: some View {
255        Button {
256            if notifying {
257                LaunchPassNotifications.unfollow(launch)
258                notifying = false
259                notifyMessage = nil
260            } else {
261                Task {
262                    notifying = await LaunchPassNotifications.follow(launch)
263                    notifyMessage = notifying ? nil : "Notifications are off for Moonrise. Turn them on in the Settings app."
264                }
265            }
266        } label: {
267            Label(notifying ? "Don't notify me" : "Notify me 15 minutes before", systemImage: notifying ? "bell.slash" : "bell")
268                .font(.subheadline.weight(.medium))
269                .frame(maxWidth: .infinity)
270                .padding(.vertical, 10)
271                .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
272        }
273        .buttonStyle(.plain)
274        .padding(.top, 4)
275    }
276
277    private var followButton: some View {
278        Button {
279            following ? unfollow() : follow()
280        } label: {
281            Label(following ? "Stop following" : "Follow on Lock Screen", systemImage: following ? "bell.slash" : "bell")
282                .font(.subheadline.weight(.medium))
283                .frame(maxWidth: .infinity)
284                .padding(.vertical, 10)
285                .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
286        }
287        .buttonStyle(.plain)
288        .onAppear {
289            following = Activity<LaunchActivityAttributes>.activities.contains { $0.attributes.launchID == launch.id }
290        }
291    }
292
293    private func follow() {
294        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
295            message = "Live Activities are turned off for Moonrise in Settings."
296            return
297        }
298        let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location)
299        let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status)
300        let content = ActivityContent(state: state, staleDate: launch.net.addingTimeInterval(3600))
301        // Live Activities end after 8 hours, so one for a later launch starts an hour before it.
302        let start = launch.net.addingTimeInterval(-3600)
303        do {
304            if start > .now {
305                let alert = AlertConfiguration(title: "\(attributes.rocket) launches in an hour", body: "\(attributes.mission)", sound: .default)
306                _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start)
307                message = "Countdown will appear on the Lock Screen an hour before launch."
308            } else {
309                _ = try Activity.request(attributes: attributes, content: content)
310                message = "Countdown added to the Lock Screen."
311            }
312            following = true
313        } catch {
314            message = "Couldn't start the countdown: \(error.localizedDescription)"
315        }
316    }
317
318    private func unfollow() {
319        let id = launch.id
320        Task {
321            for a in Activity<LaunchActivityAttributes>.activities where a.attributes.launchID == id {
322                await a.end(nil, dismissalPolicy: .immediate)
323            }
324            following = false
325            message = nil
326        }
327    }
328
329    private func fact(_ label: String, _ value: String) -> some View {
330        GridRow {
331            Text(label).dimmed(0.6)
332            Text(value)
333        }
334    }
335}