App/Sources/OnboardingScreen.swift
229 lines · 9856 bytes
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}