App/Sources/SettingsScreen.swift
272 lines · 13410 bytes
1import Library
2import SkyRenderer
3import SwiftUI
4import UserNotifications
5
6/// UserDefaults keys shared by the settings screen and the models that read them.
7enum SettingsKey {
8 static let locationMode = "locationMode" // "current" or "city"
9 static let cityName = "cityName"
10 static let cityLatitude = "cityLatitude"
11 static let cityLongitude = "cityLongitude"
12 static let nightMode = "nightMode" // "off", "on" or "auto"
13 static let onboarded = "onboarded"
14 /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab).
15 static let nightActive = "nightActive"
16 static let dailySummary = "dailySummary"
17 /// Sky culture id, or "western" for the IAU constellations.
18 static let skyCulture = "skyCulture"
19 /// Minutes after local midnight.
20 static let dailySummaryMinutes = "dailySummaryMinutes"
21 /// Messier objects on the sky chart.
22 static let showDeepSky = "showDeepSky"
23}
24
25struct City: Hashable, Identifiable {
26 var name: String
27 var latitude: Double
28 var longitude: Double
29 var id: String { name }
30
31 static let all: [City] = [
32 City(name: "Anchorage", latitude: 61.218, longitude: -149.900),
33 City(name: "Auckland", latitude: -36.849, longitude: 174.763),
34 City(name: "Berlin", latitude: 52.520, longitude: 13.405),
35 City(name: "Buenos Aires", latitude: -34.604, longitude: -58.382),
36 City(name: "Cairo", latitude: 30.044, longitude: 31.236),
37 City(name: "Cape Town", latitude: -33.925, longitude: 18.424),
38 City(name: "Chicago", latitude: 41.878, longitude: -87.630),
39 City(name: "Denver", latitude: 39.739, longitude: -104.990),
40 City(name: "Honolulu", latitude: 21.307, longitude: -157.858),
41 City(name: "Houston", latitude: 29.760, longitude: -95.370),
42 City(name: "London", latitude: 51.507, longitude: -0.128),
43 City(name: "Los Angeles", latitude: 34.052, longitude: -118.244),
44 City(name: "Mexico City", latitude: 19.433, longitude: -99.133),
45 City(name: "Mumbai", latitude: 19.076, longitude: 72.878),
46 City(name: "New York", latitude: 40.713, longitude: -74.006),
47 City(name: "Paris", latitude: 48.857, longitude: 2.352),
48 City(name: "Reykjavík", latitude: 64.147, longitude: -21.942),
49 City(name: "Santiago", latitude: -33.449, longitude: -70.669),
50 City(name: "São Paulo", latitude: -23.551, longitude: -46.633),
51 City(name: "Seattle", latitude: 47.606, longitude: -122.332),
52 City(name: "Singapore", latitude: 1.352, longitude: 103.820),
53 City(name: "Stockholm", latitude: 59.329, longitude: 18.069),
54 City(name: "Sydney", latitude: -33.869, longitude: 151.209),
55 City(name: "Tokyo", latitude: 35.676, longitude: 139.650),
56 City(name: "Toronto", latitude: 43.653, longitude: -79.383),
57 ]
58}
59
60/// Privacy policy served by the backend.
61let privacyPolicyURL = URL(string: "https://moonrise-server.audit-labs.workers.dev/privacy")!
62
63struct SettingsScreen: View {
64 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
65 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
66 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
67 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
68 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
69 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
70 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
71 @AppStorage(SettingsKey.onboarded) private var onboarded = true
72 @Environment(\.dismiss) private var dismiss
73 @State private var alerts = AuroraAlerts.shared
74 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
75 @AppStorage(SettingsKey.dailySummaryMinutes) private var dailySummaryMinutes = DailySummaryNotifications.defaultMinutes
76 @State private var summaryStatus: String?
77 var onChange: () -> Void
78
79 var body: some View {
80 NavigationStack {
81 Form {
82 Section("Location") {
83 Picker("Sky for", selection: $locationMode) {
84 Text("Current location").tag("current")
85 Text("A city").tag("city")
86 }
87 if locationMode == "city" {
88 Picker("City", selection: $cityName) {
89 ForEach(City.all) { Text($0.name).tag($0.name) }
90 }
91 }
92 }
93 Section {
94 Toggle("Aurora alerts", isOn: Binding(
95 get: { alerts.enabled },
96 set: { on in Task { await alerts.setEnabled(on) } }
97 ))
98 Toggle("News alerts", isOn: Binding(
99 get: { alerts.news },
100 set: { on in Task { await alerts.setNews(on) } }
101 ))
102 if alerts.enabled || alerts.news {
103 Button("Send a test alert") { Task { await alerts.sendTest() } }
104 }
105 if let status = alerts.status {
106 Text(status).font(.footnote).foregroundStyle(.secondary)
107 }
108 } header: {
109 Text("Alerts")
110 } footer: {
111 Text("Aurora alerts tell you when the aurora may be visible from your area at night. News alerts send three of the day's space headlines at 9 a.m. For these, Moonrise sends our server this device's notification token, a 5° band of geomagnetic latitude, your time zone offset and which alerts are on, not your location.")
112 }
113 Section {
114 Toggle("Daily summary", isOn: Binding(
115 get: { dailySummary },
116 set: { on in Task { await setDailySummary(on) } }
117 ))
118 if dailySummary {
119 DatePicker("Time", selection: summaryTime, displayedComponents: .hourAndMinute)
120 }
121 if let summaryStatus {
122 Text(summaryStatus).font(.footnote).foregroundStyle(.secondary)
123 }
124 } footer: {
125 Text("A note each day with the Moon, the planets up that night, the best satellite pass and any events. It is worked out on this device; nothing is sent to our server.")
126 }
127 Section {
128 Picker("Constellations", selection: $skyCulture) {
129 Text("Western (IAU)").tag("western")
130 ForEach(Catalog.skyCultures) { Text($0.name).tag($0.id) }
131 }
132 Toggle("Messier objects", isOn: $showDeepSky)
133 Picker("Night mode", selection: $nightMode) {
134 Text("Off").tag("off")
135 Text("On").tag("on")
136 Text("After dark").tag("auto")
137 }
138 } header: {
139 Text("Display")
140 } footer: {
141 Text("Constellations from other cultures come from Stellarium's sky cultures and are drawn over the same stars; figures with stars fainter than the app's catalogue are shortened. Messier objects are galaxies, clusters and nebulae, drawn with atlas symbols; most need binoculars. Night mode draws the sky in red to keep your eyes adapted to the dark. After dark turns it on once the Sun is 12° below the horizon.")
142 }
143 Section("About") {
144 LabeledContent("Version", value: Bundle.main.versionString)
145 Button("Show the introduction again") {
146 dismiss()
147 // After the sheet has gone, so the full-screen introduction can present.
148 Task { try? await Task.sleep(for: .milliseconds(600)); onboarded = false }
149 }
150 NavigationLink("Credits and licences") { CreditsScreen() }
151 Link("Privacy policy", destination: privacyPolicyURL)
152 }
153 }
154 .navigationTitle("Settings")
155 .navigationBarTitleDisplayMode(.inline)
156 .toolbar {
157 ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
158 }
159 .onChange(of: cityName) {
160 if let c = City.all.first(where: { $0.name == cityName }) {
161 cityLatitude = c.latitude
162 cityLongitude = c.longitude
163 }
164 onChange()
165 }
166 .onChange(of: locationMode) { onChange() }
167 }
168 }
169
170 private var summaryTime: Binding<Date> {
171 Binding(
172 get: { Calendar.current.startOfDay(for: .now).addingTimeInterval(Double(dailySummaryMinutes) * 60) },
173 set: { d in
174 let c = Calendar.current.dateComponents([.hour, .minute], from: d)
175 dailySummaryMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
176 }
177 )
178 }
179
180 private func setDailySummary(_ on: Bool) async {
181 if on {
182 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
183 guard granted else {
184 summaryStatus = "Notifications are off for Moonrise. Turn them on in the Settings app."
185 return
186 }
187 }
188 summaryStatus = nil
189 dailySummary = on
190 }
191}
192
193struct CreditsScreen: View {
194 private struct Credit: Hashable {
195 var name: String
196 var detail: String
197 }
198
199 private let software: [Credit] = [
200 Credit(name: "Astronomy Engine", detail: "Don Cross. MIT licence."),
201 Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."),
202 Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."),
203 Credit(name: "Coastlines", detail: "Natural Earth. Public domain."),
204 Credit(name: "Messier objects", detail: "Mattia Verga, OpenNGC. CC BY-SA 4.0."),
205 Credit(name: "Spacecraft trajectories", detail: "NASA/JPL Horizons."),
206 ]
207
208 private let feeds: [Credit] = [
209 Credit(name: "Space weather and aurora forecast", detail: "NOAA Space Weather Prediction Center."),
210 Credit(name: "Images of the Sun", detail: "Courtesy of NASA/SDO and the AIA, EVE, and HMI science teams."),
211 Credit(name: "Launch schedule", detail: "The Space Devs, Launch Library 2."),
212 Credit(name: "Deep Space Network status", detail: "NASA/JPL, DSN Now."),
213 Credit(name: "Satellite orbits", detail: "CelesTrak."),
214 Credit(name: "Mars rover raw images", detail: "NASA/JPL-Caltech, with MSSS and ASU for some cameras."),
215 Credit(name: "Space news headlines", detail: "Spaceflight News API, The Space Devs. Articles belong to their publishers."),
216 ]
217
218 /// Every distinct image credit used by the Library, grouped by licence.
219 private var imageCredits: [(licence: String, credits: [String])] {
220 let images = Library.pages.compactMap(\.image)
221 let grouped = Dictionary(grouping: images, by: \.licence)
222 return grouped.keys.sorted().map { licence in
223 (licence, Array(Set(grouped[licence]!.map(\.credit))).sorted())
224 }
225 }
226
227 var body: some View {
228 List {
229 Section("Data and software") {
230 ForEach(software, id: \.self) { row($0) }
231 }
232 Section("Live data") {
233 ForEach(feeds, id: \.self) { row($0) }
234 }
235 Section("Sky cultures") {
236 ForEach(Catalog.skyCultures) { c in
237 Link(destination: c.source) {
238 VStack(alignment: .leading, spacing: 2) {
239 Text(c.name)
240 Text("Stellarium sky cultures. \(c.licence)").font(.footnote).foregroundStyle(.secondary)
241 }
242 }
243 }
244 }
245 ForEach(imageCredits, id: \.licence) { group in
246 Section("Library images: \(group.licence)") {
247 ForEach(group.credits, id: \.self) { Text($0).font(.footnote) }
248 }
249 }
250 Section {
251 Text("Each Library page lists its sources, and each image links to the page it is published on. CC BY images are used under the Creative Commons Attribution 4.0 licence; ESA images marked CC BY-SA 3.0 IGO under that licence. NASA does not endorse this app.")
252 .font(.footnote)
253 }
254 }
255 .navigationTitle("Credits and licences")
256 }
257
258 private func row(_ c: Credit) -> some View {
259 VStack(alignment: .leading, spacing: 2) {
260 Text(c.name)
261 Text(c.detail).font(.footnote).foregroundStyle(.secondary)
262 }
263 }
264}
265
266extension Bundle {
267 var versionString: String {
268 let v = object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
269 let b = object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
270 return "\(v) (\(b))"
271 }
272}