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

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}