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

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/LaunchesScreen.swift history · blame · raw

237 lines · 9632 bytes

11 symbols in this file
  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}