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/EclipseDayScreen.swift

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/App/Sources/EclipseDayScreen.swift history · blame · raw

229 lines · 10976 bytes

  1import AVFoundation
  2import CoreHaptics
  3import SkyEngine
  4import SwiftUI
  5
  6/// Eclipse-day countdown: next contact, the cue script spoken with haptics, and the Sun's position.
  7/// Runs in the foreground with the screen kept awake. Rehearsal runs the same code on a clock set
  8/// to three minutes before totality.
  9struct EclipseDayScreen: View {
 10    var date: Date
 11    var observer: Observer
 12    var placeName: String
 13    var zone: TimeZone?
 14    var rehearsal: Bool
 15    var location: EclipseLocation
 16    @State private var timeline: EclipseTimeline?
 17    /// Added to the real clock; set once in rehearsal.
 18    @State private var shift: TimeInterval?
 19    @State private var player = CuePlayer()
 20    @State private var spoken: EclipseTimeline.Cue?
 21    @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
 22    @Environment(\.dismiss) private var dismiss
 23
 24    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 25    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 26
 27    private var fix: EclipseLocation.Fix? { locationMode == "city" ? nil : location.fix }
 28    private var site: Observer { fix?.observer ?? observer }
 29
 30    private func now() -> Date { Date.now.addingTimeInterval(shift ?? 0) }
 31
 32    var body: some View {
 33        NavigationStack {
 34            ScrollView {
 35                VStack(alignment: .leading, spacing: 20) {
 36                    if let timeline {
 37                        if rehearsal {
 38                            Text("Rehearsal. The clock is running from three minutes before \(timeline.eclipse.kind == "Total" ? "totality" : "maximum").")
 39                                .font(.footnote.weight(.medium)).foregroundStyle(accent)
 40                        }
 41                        TimelineView(.periodic(from: .now, by: 0.1)) { _ in countdown(timeline, at: now()) }
 42                        contacts(timeline)
 43                        SunDiagram(timeline: timeline, ink: ink, accent: accent)
 44                        status(timeline)
 45                    } else {
 46                        ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
 47                    }
 48                    safety
 49                }
 50                .padding(20)
 51                .readableWidth()
 52            }
 53            .background(Color.black.ignoresSafeArea())
 54            .foregroundStyle(ink)
 55            .navigationTitle(rehearsal ? "Rehearsal" : "Eclipse day")
 56            .navigationBarTitleDisplayMode(.inline)
 57            .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
 58        }
 59        .task(id: [site.latitude, site.longitude, site.height, (fix?.accuracy ?? .infinity) < EclipseTimeline.maximumAccuracy ? 1 : 0]) {
 60            let date = date, site = site, accuracy = fix?.accuracy
 61            let t = await Task.detached { LocalEclipse.at(site, near: date).flatMap { try? EclipseTimeline($0, observer: site, accuracy: accuracy) } }.value
 62            timeline = t
 63            if rehearsal, shift == nil, let t { shift = t.rehearsalStart.timeIntervalSinceNow }
 64        }
 65        .task {
 66            // Plays each cue once, within 50 ms of its time; cues more than 2 s late (the app was away) are skipped.
 67            var last = now()
 68            while !Task.isCancelled {
 69                try? await Task.sleep(for: .milliseconds(50))
 70                let t = now()
 71                for cue in timeline?.cues ?? [] where cue.date > last && cue.date <= t && t.timeIntervalSince(cue.date) < 2 {
 72                    player.play(cue)
 73                    spoken = cue
 74                }
 75                last = t
 76            }
 77        }
 78        .onAppear {
 79            UIApplication.shared.isIdleTimerDisabled = true
 80            if locationMode != "city" { location.start() }
 81            player.start()
 82        }
 83        .onDisappear {
 84            UIApplication.shared.isIdleTimerDisabled = false
 85            if locationMode != "city" { location.stop() }
 86            player.stop()
 87        }
 88    }
 89
 90    private func countdown(_ t: EclipseTimeline, at now: Date) -> some View {
 91        let next = t.contacts.first { $0.date > now }
 92        let inTotality = t.eclipse.centralBegin.map { $0 <= now } == true && t.eclipse.centralEnd.map { now < $0 } == true
 93        return VStack(alignment: .leading, spacing: 8) {
 94            Text(next.map { label($0.name, t) } ?? "The eclipse is over").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.7)
 95            if let next {
 96                Text(clock(next.date.timeIntervalSince(now)))
 97                    .font(.system(size: 64, weight: .medium).monospacedDigit())
 98                    .foregroundStyle(inTotality ? accent : ink)
 99                    .contentTransition(.numericText())
100            }
101            if let cue = spoken, now.timeIntervalSince(cue.date) < 20 {
102                Text(cue.text).font(.title3.weight(.medium)).foregroundStyle(cue.warning ? accent : ink)
103            }
104            let upcoming = t.cues.filter { $0.date > now && Int($0.text) == nil }.prefix(3)
105            if !upcoming.isEmpty {
106                VStack(alignment: .leading, spacing: 4) {
107                    ForEach(upcoming, id: \.date) { c in
108                        HStack(alignment: .firstTextBaseline) {
109                            Text("in \(clock(c.date.timeIntervalSince(now)))").monospacedDigit().dimmed(0.6).frame(width: 90, alignment: .leading)
110                            Text(c.text).foregroundStyle(c.warning ? accent : ink).lineLimit(2)
111                        }
112                    }
113                }
114                .font(.footnote)
115            }
116        }
117        .accessibilityElement(children: .combine)
118    }
119
120    private func label(_ contact: String, _ t: EclipseTimeline) -> String {
121        let central = t.eclipse.kind == "Total" ? "Totality" : "Annularity"
122        switch contact {
123        case "C1": return "First contact in"
124        case "C2": return "\(central) in"
125        case "Max": return "Maximum in"
126        case "C3": return "\(central) ends in"
127        default: return "Last contact in"
128        }
129    }
130
131    private func contacts(_ t: EclipseTimeline) -> some View {
132        let zones = [zone, .gmt].compactMap { $0 }
133        return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
134            GridRow {
135                Text("")
136                if let zone { Text("Local, \(utcOffset(zone, at: t.eclipse.peak))").dimmed(0.6) }
137                Text("UTC").dimmed(0.6)
138            }
139            .font(.caption)
140            ForEach(t.contacts, id: \.name) { c in
141                GridRow {
142                    Text(c.name).dimmed(0.7)
143                    ForEach(zones, id: \.self) { z in
144                        // A string: Text(_:format:) would replace the style's time zone with the environment's.
145                        Text(Date(timeIntervalSinceReferenceDate: c.date.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: z)))
146                    }
147                }
148            }
149        }
150        .font(.subheadline.monospacedDigit())
151    }
152
153    private func status(_ t: EclipseTimeline) -> some View {
154        let position = fix.map { "Position from GPS, ±\(Int($0.accuracy.rounded())) m." } ?? "Position: \(placeName), not a GPS fix."
155        let off: String = switch t.glassesOffWithheld {
156        case nil: "The totality cue tells you to take your glasses off only once the last bead of sunlight has gone."
157        case .partial: "No totality here: keep your glasses on the whole time."
158        case .short: "Totality here is under \(Int(EclipseTimeline.minimumTotality)) s, so there is no glasses-off cue."
159        case .imprecise: locationMode == "city"
160            ? "There is no glasses-off cue for a chosen city. Choose Current location in Settings for one."
161            : "There is no glasses-off cue until the GPS fix is better than \(Int(EclipseTimeline.maximumAccuracy)) m."
162        case .sunDown: "The Sun is below the horizon at totality here."
163        }
164        return VStack(alignment: .leading, spacing: 6) {
165            Text(position)
166            Text(off)
167            Text("Times are good to about 2 s inside the path and less near its edges, where valleys on the Moon's limb shift the contacts.")
168                .dimmed(0.7)
169        }
170        .font(.footnote)
171    }
172
173    private var safety: some View {
174        VStack(alignment: .leading, spacing: 6) {
175            Text("Safety").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
176            Text("Look at the Sun only through eclipse glasses or a viewer made to ISO 12312-2, except during totality itself.")
177            Text("Cameras, binoculars and telescopes need their own solar filters. Never look through them with glasses alone.")
178            Text("Do not drive during totality. Keep the phone in the shade: in direct sun it can overheat and shut down.")
179        }
180        .font(.footnote)
181        .foregroundStyle(accent)
182    }
183
184    /// "4:39" or "1:02:15", rounded up to the second.
185    private func clock(_ seconds: TimeInterval) -> String {
186        let s = Int(max(0, seconds).rounded(.up))
187        return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s % 3600 / 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
188    }
189}
190
191/// Speaks cues and plays their haptics. The audio session uses the playback category so speech
192/// sounds with the ring switch on silent.
193@MainActor
194final class CuePlayer {
195    private let speech = AVSpeechSynthesizer()
196    private var haptics: CHHapticEngine?
197
198    func start() {
199        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .voicePrompt, options: [.duckOthers])
200        try? AVAudioSession.sharedInstance().setActive(true)
201        haptics = try? CHHapticEngine()
202    }
203
204    func stop() {
205        speech.stopSpeaking(at: .immediate)
206        haptics?.stop()
207        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
208    }
209
210    func play(_ cue: EclipseTimeline.Cue) {
211        speech.stopSpeaking(at: .immediate)
212        let u = AVSpeechUtterance(string: cue.text)
213        u.voice = AVSpeechSynthesisVoice(language: "en-US")
214        speech.speak(u)
215
216        let events: [CHHapticEvent] = switch cue.haptic {
217        case .tap: [tap(at: 0)]
218        case .double: [tap(at: 0), tap(at: 0.15)]
219        case .long: [CHHapticEvent(eventType: .hapticContinuous, parameters: [.init(parameterID: .hapticIntensity, value: 1)], relativeTime: 0, duration: 0.8)]
220        }
221        guard let haptics, let pattern = try? CHHapticPattern(events: events, parameters: []) else { return }
222        try? haptics.start()
223        try? haptics.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate)
224    }
225
226    private func tap(at time: TimeInterval) -> CHHapticEvent {
227        CHHapticEvent(eventType: .hapticTransient, parameters: [.init(parameterID: .hapticIntensity, value: 0.8)], relativeTime: time)
228    }
229}