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