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