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

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Sources/SettingsScreen.swift history · blame · raw

272 lines · 13410 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    /// 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}