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