App/Sources/LaunchesScreen.swift
244 lines · 9931 bytes
1import ActivityKit
2import Feeds
3import Library
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, and what Siri or Spotlight asked to show.
11@MainActor
12@Observable
13final class Router {
14 static let shared = Router()
15 var tab = "sky"
16 var showNews = false
17 /// Presented as a sheet over any tab.
18 var libraryPage: LibraryPage?
19 /// Typed into the Library's search field.
20 var libraryQuery: String?
21 /// Turned to by the Sky tab once its scene exists.
22 var skyTarget: Tour.Target?
23}
24
25struct LaunchesScreen: View {
26 @State private var router = Router.shared
27 @State private var list: LaunchList?
28 @State private var error: String?
29 @State private var selected: Launch?
30 @State private var newsReload = 0
31
32 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
33 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
34 private let background = Color.black
35
36 var body: some View {
37 ScrollView {
38 VStack(alignment: .leading, spacing: 0) {
39 Text("News & launches").font(.title.weight(.medium))
40 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
41 Picker("Show", selection: $router.showNews) {
42 Text("Launches").tag(false)
43 Text("News").tag(true)
44 }
45 .pickerStyle(.segmented)
46 .padding(.bottom, 8)
47
48 if router.showNews {
49 NewsList(ink: ink, accent: accent, reload: newsReload)
50 } else {
51 launches
52 }
53 }
54 .padding(.horizontal, 20)
55 .readableWidth()
56 }
57 .foregroundStyle(ink)
58 .background(background.ignoresSafeArea())
59 .refreshable { if router.showNews { newsReload += 1 } else { await load(force: true) } }
60 .task { await load() }
61 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
62 }
63
64 @ViewBuilder
65 private var launches: some View {
66 if let error {
67 Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
68 }
69 if let list {
70 ForEach(Array(list.launches.enumerated()), id: \.element.id) { i, launch in
71 Button { selected = launch } label: { row(launch, index: i + 1) }
72 .buttonStyle(.plain)
73 }
74 Text("Launch data: The Space Devs, Launch Library 2.")
75 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
76 } else if error == nil {
77 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
78 }
79 }
80
81 /// The backend polls every 10 minutes; a visit within 5 minutes of the last load reuses it.
82 private func load(force: Bool = false) async {
83 guard let apiBase else { error = "No backend configured for this build."; return }
84 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
85 do {
86 list = try await LaunchFeed.fetch(base: apiBase)
87 if let list { FeedMemo.set("launches", list) }
88 error = nil
89 } catch {
90 self.error = "Couldn't load launches. Pull to retry."
91 }
92 }
93
94 private func row(_ l: Launch, index: Int) -> some View {
95 VStack(alignment: .leading, spacing: 6) {
96 HStack(alignment: .firstTextBaseline) {
97 Text(String(format: "%02d", index)).font(.caption2.monospacedDigit()).dimmed(0.5)
98 Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
99 .font(.caption2).dimmed(0.7)
100 Spacer()
101 StatusTag(status: l.status, ink: ink, accent: accent)
102 }
103 Text(l.rocket).font(.title2.weight(.medium))
104 if let m = l.mission { Text(m).font(.subheadline) }
105 HStack {
106 Text("\(l.provider) · \(l.location)").font(.caption2).dimmed(0.7)
107 Spacer()
108 Countdown(to: l.net).font(.footnote.weight(.medium).monospacedDigit())
109 }
110 }
111 .padding(.vertical, 14)
112 .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
113 .contentShape(Rectangle())
114 }
115}
116
117struct StatusTag: View {
118 var status: String
119 var ink: Color
120 var accent: Color
121
122 var body: some View {
123 Text(status.uppercased())
124 .font(.caption2.weight(.medium))
125 .tracking(0.6)
126 .padding(.horizontal, 6).padding(.vertical, 2)
127 .foregroundStyle(status == "Go" ? Color.black : ink)
128 .background(status == "Go" ? ink : (status == "Hold" ? accent : ink.opacity(0.15)), in: .rect(cornerRadius: 2))
129 }
130}
131
132/// T− countdown, ticking each second; T+ once the time has passed.
133struct Countdown: View {
134 var to: Date
135
136 var body: some View {
137 TimelineView(.periodic(from: .now, by: 1)) { ctx in
138 Text(Self.format(to.timeIntervalSince(ctx.date)))
139 }
140 }
141
142 static func format(_ seconds: TimeInterval) -> String {
143 let s = Int(abs(seconds))
144 let sign = seconds >= 0 ? "T−" : "T+"
145 let d = s / 86_400, h = (s % 86_400) / 3600, m = (s % 3600) / 60, sec = s % 60
146 return d > 0 ? String(format: "%@%dd %02d:%02d:%02d", sign, d, h, m, sec) : String(format: "%@%02d:%02d:%02d", sign, h, m, sec)
147 }
148}
149
150struct LaunchDetail: View {
151 var launch: Launch
152 var ink: Color
153 var accent: Color
154 var background: Color
155
156 var body: some View {
157 ScrollView {
158 VStack(alignment: .leading, spacing: 10) {
159 HStack {
160 StatusTag(status: launch.status, ink: ink, accent: accent)
161 Text(launch.statusName).font(.caption).dimmed(0.7)
162 }
163 Text(launch.rocket).font(.title.weight(.medium))
164 if let m = launch.mission { Text(m).font(.body) }
165 Countdown(to: launch.net).font(.largeTitle.weight(.medium).monospacedDigit()).padding(.vertical, 6)
166 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
167 fact("Provider", launch.provider)
168 fact("Launch time", launch.net.formatted(date: .abbreviated, time: .shortened))
169 if let a = launch.windowStart, let b = launch.windowEnd, a != b {
170 fact("Window", "\(a.formatted(date: .omitted, time: .shortened))–\(b.formatted(date: .omitted, time: .shortened))")
171 }
172 if let t = launch.missionType { fact("Mission", t) }
173 if let o = launch.orbit { fact("Orbit", o) }
174 fact("Pad", launch.pad)
175 fact("Location", launch.location)
176 }
177 .font(.subheadline)
178 followButton.padding(.top, 8)
179 if let message { Text(message).font(.caption).dimmed(0.7) }
180 if let d = launch.missionDescription {
181 Text(d).font(.subheadline).dimmed(0.85).padding(.top, 8)
182 }
183 }
184 .padding(20)
185 .frame(maxWidth: .infinity, alignment: .leading)
186 }
187 .foregroundStyle(ink)
188 .presentationBackground(background)
189 .presentationDetents([.medium, .large])
190 }
191
192 @State private var message: String?
193 @State private var following = false
194
195 private var followButton: some View {
196 Button {
197 following ? unfollow() : follow()
198 } label: {
199 Label(following ? "Stop following" : "Follow on Lock Screen", systemImage: following ? "bell.slash" : "bell")
200 .font(.subheadline.weight(.medium))
201 .frame(maxWidth: .infinity)
202 .padding(.vertical, 10)
203 .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
204 }
205 .buttonStyle(.plain)
206 .onAppear {
207 following = Activity<LaunchActivityAttributes>.activities.contains { $0.attributes.launchID == launch.id }
208 }
209 }
210
211 private func follow() {
212 guard ActivityAuthorizationInfo().areActivitiesEnabled else {
213 message = "Live Activities are turned off for Moonrise in Settings."
214 return
215 }
216 let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location)
217 let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status)
218 do {
219 _ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: launch.net.addingTimeInterval(3600)))
220 following = true
221 message = "Countdown added to the Lock Screen."
222 } catch {
223 message = "Couldn't start the countdown: \(error.localizedDescription)"
224 }
225 }
226
227 private func unfollow() {
228 let id = launch.id
229 Task {
230 for a in Activity<LaunchActivityAttributes>.activities where a.attributes.launchID == id {
231 await a.end(nil, dismissalPolicy: .immediate)
232 }
233 following = false
234 message = nil
235 }
236 }
237
238 private func fact(_ label: String, _ value: String) -> some View {
239 GridRow {
240 Text(label).dimmed(0.6)
241 Text(value)
242 }
243 }
244}