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

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

229 lines · 9856 bytes

7 symbols in this file
  1import SkyRenderer
  2import SwiftUI
  3import UserNotifications
  4
  5/// First launch: what the app does, optional alerts, and location. Can be shown again from Settings.
  6struct OnboardingScreen: View {
  7    @AppStorage(SettingsKey.onboarded) private var onboarded = false
  8    @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
  9    @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
 10    @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
 11    @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
 12    @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
 13    @State private var alerts = AuroraAlerts.shared
 14    @State private var page = 0
 15    @State private var choosingCity = false
 16    @State private var notificationsDenied = false
 17
 18    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 19    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 20    private let pages = 5
 21
 22    var body: some View {
 23        VStack(spacing: 0) {
 24            HStack {
 25                Spacer()
 26                if page < pages - 1 {
 27                    Button("Skip") { withAnimation { page = pages - 1 } }
 28                        .font(.subheadline.weight(.medium))
 29                        .foregroundStyle(ink.opacity(0.7))
 30                        .frame(minHeight: 44)
 31                }
 32            }
 33            .padding(.horizontal, 24)
 34            .frame(height: 44)
 35
 36            TabView(selection: $page) {
 37                welcome.tag(0)
 38                sky.tag(1)
 39                beyond.tag(2)
 40                loop.tag(3)
 41                location.tag(4)
 42            }
 43            .tabViewStyle(.page(indexDisplayMode: .never))
 44
 45            HStack(spacing: 6) {
 46                ForEach(0..<pages, id: \.self) { i in
 47                    Capsule().fill(i == page ? ink : ink.opacity(0.25)).frame(width: i == page ? 18 : 6, height: 6)
 48                }
 49            }
 50            .animation(.easeInOut(duration: 0.2), value: page)
 51            .accessibilityHidden(true)
 52            .padding(.bottom, 16)
 53
 54            if page < pages - 1 {
 55                button("Continue", filled: true) { withAnimation { page += 1 } }
 56                    .padding(.horizontal, 24)
 57            }
 58        }
 59        .padding(.bottom, 24)
 60        .foregroundStyle(ink)
 61        .background(Color.black.ignoresSafeArea())
 62        .tint(accent)
 63    }
 64
 65    // MARK: Pages
 66
 67    private var welcome: some View {
 68        VStack(alignment: .leading, spacing: 0) {
 69            Spacer()
 70            MoonDisc(fraction: 0.55, waxing: true)
 71                .frame(width: 96, height: 96)
 72                .padding(.bottom, 28)
 73                .accessibilityHidden(true)
 74            Text("Moonrise").font(.largeTitle.weight(.medium))
 75            Rectangle().fill(accent).frame(width: 48, height: 5).padding(.vertical, 14)
 76            Text("The night sky, the Sun and spaceflight in one place: what's up tonight, what's coming, and the story behind it.")
 77                .font(.title3)
 78            Spacer()
 79            Spacer()
 80        }
 81        .padding(.horizontal, 24)
 82        .frame(maxWidth: 560, alignment: .leading)
 83        .frame(maxWidth: .infinity)
 84    }
 85
 86    private var sky: some View {
 87        pageLayout {
 88            heading("Your sky, tonight", "The Sky tab shows the stars and planets above you right now.")
 89            point("hand.tap", "Tap anything", "A star, planet or constellation shows what it is and opens its Library page.")
 90            point("hand.draw", "Look around", "Drag to turn and pinch to zoom, or point your phone at the sky from the Explore menu.")
 91            point("clock", "Change the time", "Slide along the bottom to see the sky later tonight.")
 92            point("figure.walk", "Take a tour", "Guided tours walk you through the brightest sights of the season, one at a time.")
 93        }
 94    }
 95
 96    private var beyond: some View {
 97        pageLayout {
 98            heading("Beyond tonight", "Four more tabs, each answering a different question.")
 99            point("circle.circle", "System", "Where the planets and the spacecraft heading out of the solar system are, from 1958 to 2050.")
100            point("sun.max", "Sun", "Today's Sun from NASA, solar flares, and where the aurora may be visible tonight.")
101            point("airplane.departure", "News & launches", "Upcoming rocket launches with countdowns, and the day's space news.")
102            point("books.vertical", "Library", "370 illustrated pages on planets, stars, missions, rockets and more.")
103        }
104    }
105
106    private var loop: some View {
107        pageLayout {
108            heading("Stay in the loop", "The sky changes every night. Moonrise can tell you when something is worth stepping outside for. All optional; change them any time in Settings.")
109            toggle("Daily summary", "Each evening: the Moon, the planets up, the best satellite pass and any events. Worked out on your phone.", isOn: Binding(
110                get: { dailySummary },
111                set: { on in Task {
112                    if on, !(await permission()) { return }
113                    dailySummary = on
114                } }
115            ))
116            toggle("Aurora alerts", "When the aurora may be visible from your area at night.", isOn: Binding(
117                get: { alerts.enabled },
118                set: { on in Task { await alerts.setEnabled(on) } }
119            ))
120            toggle("Space news", "Three of the day's headlines at 9 a.m.", isOn: Binding(
121                get: { alerts.news },
122                set: { on in Task { await alerts.setNews(on) } }
123            ))
124            if notificationsDenied || alerts.status != nil {
125                Text("Notifications are off for Moonrise. You can turn them on in the Settings app.")
126                    .font(.footnote).foregroundStyle(accent)
127            }
128        }
129    }
130
131    private var location: some View {
132        VStack(alignment: .leading, spacing: 0) {
133            heading("Where are you?", "Moonrise draws the sky for where you are. Your location is used on this phone to work out positions and is never sent to us or anyone else.")
134            Spacer(minLength: 0)
135            if choosingCity {
136                Picker("City", selection: $cityName) {
137                    ForEach(City.all) { Text($0.name).tag($0.name) }
138                }
139                .pickerStyle(.wheel)
140                .frame(height: 140)
141                button("Use \(cityName)", filled: true) {
142                    if let c = City.all.first(where: { $0.name == cityName }) {
143                        cityLatitude = c.latitude
144                        cityLongitude = c.longitude
145                    }
146                    locationMode = "city"
147                    onboarded = true
148                }
149                button("Back", filled: false) { choosingCity = false }
150            } else {
151                button("Use my location", filled: true) {
152                    locationMode = "current"
153                    onboarded = true
154                }
155                button("Choose a city instead", filled: false) { choosingCity = true }
156            }
157        }
158        .padding(.horizontal, 24)
159        .padding(.top, 24)
160        .frame(maxWidth: 560, alignment: .leading)
161        .frame(maxWidth: .infinity)
162    }
163
164    // MARK: Parts
165
166    private func pageLayout(@ViewBuilder _ content: () -> some View) -> some View {
167        ScrollView {
168            VStack(alignment: .leading, spacing: 0) {
169                content()
170            }
171            .padding(.horizontal, 24)
172            .padding(.top, 24)
173            .frame(maxWidth: 560, alignment: .leading)
174            .frame(maxWidth: .infinity)
175        }
176        .scrollBounceBehavior(.basedOnSize)
177    }
178
179    private func heading(_ title: String, _ text: String) -> some View {
180        VStack(alignment: .leading, spacing: 10) {
181            Text(title).font(.title.weight(.medium))
182            Rectangle().fill(accent).frame(width: 48, height: 5)
183            Text(text).font(.body).foregroundStyle(ink.opacity(0.75))
184        }
185        .padding(.bottom, 24)
186    }
187
188    private func point(_ symbol: String, _ title: String, _ text: String) -> some View {
189        HStack(alignment: .top, spacing: 14) {
190            Image(systemName: symbol).font(.title3).frame(width: 30).foregroundStyle(accent).accessibilityHidden(true)
191            VStack(alignment: .leading, spacing: 3) {
192                Text(title).font(.headline)
193                Text(text).font(.subheadline).foregroundStyle(ink.opacity(0.75))
194            }
195        }
196        .padding(.bottom, 18)
197        .accessibilityElement(children: .combine)
198    }
199
200    private func toggle(_ title: String, _ text: String, isOn: Binding<Bool>) -> some View {
201        Toggle(isOn: isOn) {
202            VStack(alignment: .leading, spacing: 3) {
203                Text(title).font(.headline)
204                Text(text).font(.subheadline).foregroundStyle(ink.opacity(0.75))
205            }
206        }
207        .padding(.bottom, 18)
208    }
209
210    private func permission() async -> Bool {
211        let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
212        notificationsDenied = !granted
213        return granted
214    }
215
216    private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View {
217        Button(action: action) {
218            Text(title)
219                .font(.headline)
220                .frame(maxWidth: .infinity)
221                .padding(.vertical, 14)
222                .foregroundStyle(filled ? Color.black : ink)
223                .background(filled ? ink : .clear, in: .rect(cornerRadius: 12))
224                .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5))
225        }
226        .buttonStyle(.plain)
227        .padding(.top, 10)
228    }
229}