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

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}