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