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