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

main
moonrise/App/Sources/SettingsScreen.swift history · blame · raw

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}