Polish: tours, toolbar, introduction, sheets, explanations, in-app links !41
13 files changed, +491 −132
Layout: unified · split
App/Sources/DSNSection.swift +2
| @@ -15,6 +15,8 @@ struct DSNSection: View { | |||
| 15 | var body: some View { | 15 | var body: some View { |
| 16 | VStack(alignment: .leading, spacing: 10) { | 16 | VStack(alignment: .leading, spacing: 10) { |
| 17 | Text("Deep Space Network now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) | 17 | Text("Deep Space Network now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 18 | Text("NASA talks to its deep-space probes through giant dishes at three sites spaced around the world, so one always faces any spacecraft. This is who each site is talking to now.") | ||
| 19 | .font(.footnote).dimmed(0.75) | ||
| 18 | if let status { | 20 | if let status { |
| 19 | ForEach(["Goldstone", "Madrid", "Canberra"], id: \.self) { station in | 21 | ForEach(["Goldstone", "Madrid", "Canberra"], id: \.self) { station in |
| 20 | let dishes = activeDishes.filter { $0.station == station } | 22 | let dishes = activeDishes.filter { $0.station == station } |
App/Sources/EventsScreen.swift +61 −37
| @@ -24,67 +24,91 @@ struct EventsScreen: View { | |||
| 24 | 24 | ||
| 25 | var body: some View { | 25 | var body: some View { |
| 26 | NavigationStack { | 26 | NavigationStack { |
| 27 | List { | 27 | ScrollView { |
| 28 | Section { | 28 | LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { |
| 29 | Text("Events").font(.title.weight(.medium)) | ||
| 30 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) | ||
| 31 | Text("Eclipses, meteor showers and planets at their best over the next year, as seen \(placeName == "Your location" ? "from where you are" : "from \(placeName)"). Tap a solar eclipse for its path and local times.") | ||
| 32 | .font(.subheadline).foregroundStyle(ink.opacity(0.75)) | ||
| 29 | Picker("Show", selection: $filter) { | 33 | Picker("Show", selection: $filter) { |
| 30 | Text("Notable").tag("notable") | 34 | Text("Highlights").tag("notable") |
| 31 | Text("Everything").tag("all") | 35 | Text("Everything").tag("all") |
| 32 | } | 36 | } |
| 33 | .pickerStyle(.segmented) | 37 | .pickerStyle(.segmented) |
| 34 | .listRowBackground(Color.clear) | 38 | .padding(.vertical, 16) |
| 35 | } footer: { | 39 | |
| 36 | Text("Visibility is for \(placeName). Meteor shower peaks are typical dates and can shift by a day.") | 40 | if events.isEmpty { |
| 37 | } | 41 | ProgressView().tint(ink).frame(maxWidth: .infinity).padding(.top, 40) |
| 38 | if events.isEmpty { | 42 | } |
| 39 | ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear) | 43 | ForEach(months, id: \.0) { month, items in |
| 40 | } | 44 | Section { |
| 41 | ForEach(months, id: \.0) { month, items in | 45 | ForEach(items) { e in |
| 42 | Section(month) { | 46 | if e.kind == .solarEclipse { |
| 43 | ForEach(items) { e in | 47 | Button { eclipse = e } label: { row(e, link: true) } |
| 44 | if e.kind == .solarEclipse { | 48 | .buttonStyle(.plain) |
| 45 | Button { eclipse = e } label: { row(e) } | 49 | .accessibilityHint("Shows the path and local times") |
| 46 | .buttonStyle(.plain) | 50 | } else { |
| 47 | .accessibilityHint("Shows the path and local times") | 51 | row(e, link: false) |
| 48 | } else { | 52 | } |
| 49 | row(e) | ||
| 50 | } | 53 | } |
| 54 | } header: { | ||
| 55 | Text(month).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8) | ||
| 56 | .foregroundStyle(ink.opacity(0.6)) | ||
| 57 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 58 | .padding(.top, 20).padding(.bottom, 6) | ||
| 59 | .background(Color.black) | ||
| 51 | } | 60 | } |
| 52 | } | 61 | } |
| 62 | Text("Meteor shower peaks are typical dates and can shift by a day.") | ||
| 63 | .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.vertical, 20) | ||
| 53 | } | 64 | } |
| 65 | .padding(.horizontal, 20) | ||
| 66 | .readableWidth() | ||
| 54 | } | 67 | } |
| 55 | .listStyle(.insetGrouped) | ||
| 56 | .scrollContentBackground(.hidden) | ||
| 57 | .background(Color.black.ignoresSafeArea()) | 68 | .background(Color.black.ignoresSafeArea()) |
| 58 | .navigationTitle("Events") | ||
| 59 | .navigationBarTitleDisplayMode(.inline) | ||
| 60 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } | 69 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } |
| 61 | .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) } | 70 | .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) } |
| 62 | } | 71 | } |
| 63 | .foregroundStyle(ink) | 72 | .foregroundStyle(ink) |
| 73 | .tint(accent) | ||
| 64 | .task { | 74 | .task { |
| 65 | let observer = observer | 75 | let observer = observer |
| 66 | events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value | 76 | events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value |
| 67 | } | 77 | } |
| 68 | } | 78 | } |
| 69 | 79 | ||
| 70 | private func row(_ e: SkyEvent) -> some View { | 80 | private func row(_ e: SkyEvent, link: Bool) -> some View { |
| 71 | HStack(alignment: .firstTextBaseline, spacing: 12) { | 81 | HStack(alignment: .top, spacing: 14) { |
| 72 | Image(systemName: symbol(e.kind)).frame(width: 22).dimmed(0.8).accessibilityHidden(true) | 82 | VStack(spacing: 0) { |
| 73 | VStack(alignment: .leading, spacing: 3) { | 83 | Text(e.date.formatted(.dateTime.day())).font(.title2.weight(.medium).monospacedDigit()) |
| 74 | Text(e.title).font(.body) | 84 | Text(e.date.formatted(.dateTime.weekday(.abbreviated))).font(.caption2).textCase(.uppercase).foregroundStyle(ink.opacity(0.6)) |
| 75 | Text(e.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())) | 85 | } |
| 76 | .font(.footnote.monospacedDigit()).dimmed(0.7) | 86 | .frame(width: 40) |
| 77 | if !e.detail.isEmpty { Text(e.detail).font(.footnote).dimmed(0.8) } | 87 | VStack(alignment: .leading, spacing: 4) { |
| 88 | HStack(alignment: .firstTextBaseline, spacing: 6) { | ||
| 89 | Image(systemName: symbol(e.kind)).font(.subheadline).foregroundStyle(e.visibleHere == true ? accent : ink.opacity(0.7)) | ||
| 90 | .accessibilityHidden(true) | ||
| 91 | Text(e.title).font(.body.weight(.medium)) | ||
| 92 | } | ||
| 93 | Text(e.date.formatted(date: .omitted, time: .shortened)).font(.footnote.monospacedDigit()).foregroundStyle(ink.opacity(0.6)) | ||
| 94 | if !e.detail.isEmpty { Text(e.detail).font(.footnote).foregroundStyle(ink.opacity(0.8)) } | ||
| 78 | if let v = e.visibleHere { | 95 | if let v = e.visibleHere { |
| 79 | Text(v ? "Visible from here" : "Not visible from here") | 96 | HStack(spacing: 5) { |
| 80 | .font(.caption.weight(.medium)) | 97 | Circle().fill(v ? accent : ink.opacity(0.35)).frame(width: 6, height: 6) |
| 81 | .foregroundStyle(v ? ink : ink.opacity(0.7)) | 98 | Text(v ? "Visible from here" : "Not visible from here").font(.caption.weight(.medium)) |
| 82 | .padding(.horizontal, 6).padding(.vertical, 2) | 99 | .foregroundStyle(v ? ink : ink.opacity(0.6)) |
| 83 | .overlay(RoundedRectangle(cornerRadius: 3).stroke(v ? accent : ink.opacity(0.4), lineWidth: 1)) | 100 | } |
| 101 | .padding(.top, 2) | ||
| 84 | } | 102 | } |
| 85 | } | 103 | } |
| 104 | Spacer(minLength: 0) | ||
| 105 | if link { | ||
| 106 | Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(ink.opacity(0.4)).padding(.top, 6) | ||
| 107 | } | ||
| 86 | } | 108 | } |
| 87 | .padding(.vertical, 4) | 109 | .padding(.vertical, 12) |
| 110 | .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) } | ||
| 111 | .contentShape(Rectangle()) | ||
| 88 | .accessibilityElement(children: .combine) | 112 | .accessibilityElement(children: .combine) |
| 89 | } | 113 | } |
| 90 | 114 | ||
App/Sources/InAppBrowser.swift added +24
| @@ -0,0 +1,24 @@ | |||
| 1 | import SafariServices | ||
| 2 | import SwiftUI | ||
| 3 | import UIKit | ||
| 4 | |||
| 5 | /// Opens web links in Safari's in-app browser over the current screen, so people stay in the app. | ||
| 6 | /// Other schemes (mailto:, settings) go to the system as usual. | ||
| 7 | @MainActor | ||
| 8 | enum InAppBrowser { | ||
| 9 | static let action = OpenURLAction { url in | ||
| 10 | guard url.scheme == "https" || url.scheme == "http", let top = topViewController() else { return .systemAction } | ||
| 11 | let safari = SFSafariViewController(url: url) | ||
| 12 | safari.preferredControlTintColor = UIColor(red: 0.88, green: 0.24, blue: 0.19, alpha: 1) | ||
| 13 | safari.dismissButtonStyle = .done | ||
| 14 | top.present(safari, animated: true) | ||
| 15 | return .handled | ||
| 16 | } | ||
| 17 | |||
| 18 | private static func topViewController() -> UIViewController? { | ||
| 19 | let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first { $0.activationState == .foregroundActive } | ||
| 20 | var top = scene?.keyWindow?.rootViewController | ||
| 21 | while let presented = top?.presentedViewController { top = presented } | ||
| 22 | return top | ||
| 23 | } | ||
| 24 | } | ||
App/Sources/MarsSection.swift +2
| @@ -10,6 +10,8 @@ struct MarsSection: View { | |||
| 10 | var body: some View { | 10 | var body: some View { |
| 11 | VStack(alignment: .leading, spacing: 10) { | 11 | VStack(alignment: .leading, spacing: 10) { |
| 12 | Text("Mars now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) | 12 | Text("Mars now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 13 | Text("The time of day where NASA's two rovers are working, and their newest raw photos, usually a few hours old.") | ||
| 14 | .font(.footnote).dimmed(0.75) | ||
| 13 | VStack(alignment: .leading, spacing: 8) { | 15 | VStack(alignment: .leading, spacing: 8) { |
| 14 | ForEach(MarsTime.Rover.active) { rover in | 16 | ForEach(MarsTime.Rover.active) { rover in |
| 15 | TimelineView(.periodic(from: .now, by: 1)) { ctx in | 17 | TimelineView(.periodic(from: .now, by: 1)) { ctx in |
App/Sources/MoonriseApp.swift +1
| @@ -26,6 +26,7 @@ struct MoonriseApp: App { | |||
| 26 | // Red tab highlight while the Sky tab is in night mode. | 26 | // Red tab highlight while the Sky tab is in night mode. |
| 27 | .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil) | 27 | .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil) |
| 28 | .preferredColorScheme(.dark) | 28 | .preferredColorScheme(.dark) |
| 29 | .environment(\.openURL, InAppBrowser.action) | ||
| 29 | .fullScreenCover(isPresented: .constant(!onboarded)) { | 30 | .fullScreenCover(isPresented: .constant(!onboarded)) { |
| 30 | OnboardingScreen().preferredColorScheme(.dark) | 31 | OnboardingScreen().preferredColorScheme(.dark) |
| 31 | } | 32 | } |
App/Sources/OnboardingScreen.swift +173 −15
| @@ -1,30 +1,137 @@ | |||
| 1 | import SkyRenderer | ||
| 1 | import SwiftUI | 2 | import SwiftUI |
| 3 | import UserNotifications | ||
| 2 | 4 | ||
| 3 | /// First launch: what the app does and why it asks for location, before iOS asks. | 5 | /// First launch: what the app does, optional alerts, and location. Can be shown again from Settings. |
| 4 | struct OnboardingScreen: View { | 6 | struct OnboardingScreen: View { |
| 5 | @AppStorage(SettingsKey.onboarded) private var onboarded = false | 7 | @AppStorage(SettingsKey.onboarded) private var onboarded = false |
| 6 | @AppStorage(SettingsKey.locationMode) private var locationMode = "current" | 8 | @AppStorage(SettingsKey.locationMode) private var locationMode = "current" |
| 7 | @AppStorage(SettingsKey.cityName) private var cityName = "Chicago" | 9 | @AppStorage(SettingsKey.cityName) private var cityName = "Chicago" |
| 8 | @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878 | 10 | @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878 |
| 9 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 | 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 | ||
| 10 | @State private var choosingCity = false | 15 | @State private var choosingCity = false |
| 16 | @State private var notificationsDenied = false | ||
| 11 | 17 | ||
| 12 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 18 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 13 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | 19 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| 14 | private let background = Color.black | 20 | private let pages = 5 |
| 15 | 21 | ||
| 16 | var body: some View { | 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 { | ||
| 17 | VStack(alignment: .leading, spacing: 0) { | 68 | VStack(alignment: .leading, spacing: 0) { |
| 18 | Spacer() | 69 | Spacer() |
| 70 | MoonDisc(fraction: 0.55, waxing: true) | ||
| 71 | .frame(width: 96, height: 96) | ||
| 72 | .padding(.bottom, 28) | ||
| 73 | .accessibilityHidden(true) | ||
| 19 | Text("Moonrise").font(.largeTitle.weight(.medium)) | 74 | Text("Moonrise").font(.largeTitle.weight(.medium)) |
| 20 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24) | 75 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.vertical, 14) |
| 21 | VStack(alignment: .leading, spacing: 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.") |
| 22 | Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.") | 77 | .font(.title3) |
| 23 | Text("To draw the sky for where you are, Moonrise uses your location. It is used on this device to work out positions and is not sent to us or anyone else.") | ||
| 24 | .foregroundStyle(ink.opacity(0.75)) | ||
| 25 | } | ||
| 26 | .font(.body) | ||
| 27 | Spacer() | 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) | ||
| 28 | if choosingCity { | 135 | if choosingCity { |
| 29 | Picker("City", selection: $cityName) { | 136 | Picker("City", selection: $cityName) { |
| 30 | ForEach(City.all) { Text($0.name).tag($0.name) } | 137 | ForEach(City.all) { Text($0.name).tag($0.name) } |
| @@ -39,6 +146,7 @@ struct OnboardingScreen: View { | |||
| 39 | locationMode = "city" | 146 | locationMode = "city" |
| 40 | onboarded = true | 147 | onboarded = true |
| 41 | } | 148 | } |
| 149 | button("Back", filled: false) { choosingCity = false } | ||
| 42 | } else { | 150 | } else { |
| 43 | button("Use my location", filled: true) { | 151 | button("Use my location", filled: true) { |
| 44 | locationMode = "current" | 152 | locationMode = "current" |
| @@ -48,11 +156,61 @@ struct OnboardingScreen: View { | |||
| 48 | } | 156 | } |
| 49 | } | 157 | } |
| 50 | .padding(.horizontal, 24) | 158 | .padding(.horizontal, 24) |
| 51 | .padding(.bottom, 24) | 159 | .padding(.top, 24) |
| 52 | .frame(maxWidth: 560) | 160 | .frame(maxWidth: 560, alignment: .leading) |
| 53 | .frame(maxWidth: .infinity) | 161 | .frame(maxWidth: .infinity) |
| 54 | .foregroundStyle(ink) | 162 | } |
| 55 | .background(background.ignoresSafeArea()) | 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 | ||
| 56 | } | 214 | } |
| 57 | 215 | ||
| 58 | private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View { | 216 | private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View { |
| @@ -62,8 +220,8 @@ struct OnboardingScreen: View { | |||
| 62 | .frame(maxWidth: .infinity) | 220 | .frame(maxWidth: .infinity) |
| 63 | .padding(.vertical, 14) | 221 | .padding(.vertical, 14) |
| 64 | .foregroundStyle(filled ? Color.black : ink) | 222 | .foregroundStyle(filled ? Color.black : ink) |
| 65 | .background(filled ? ink : .clear, in: .rect(cornerRadius: 8)) | 223 | .background(filled ? ink : .clear, in: .rect(cornerRadius: 12)) |
| 66 | .overlay(RoundedRectangle(cornerRadius: 8).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5)) | 224 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5)) |
| 67 | } | 225 | } |
| 68 | .buttonStyle(.plain) | 226 | .buttonStyle(.plain) |
| 69 | .padding(.top, 10) | 227 | .padding(.top, 10) |
App/Sources/SettingsScreen.swift +6
| @@ -65,6 +65,7 @@ struct SettingsScreen: View { | |||
| 65 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 | 65 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 |
| 66 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" | 66 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 67 | @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" | 67 | @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" |
| 68 | @AppStorage(SettingsKey.onboarded) private var onboarded = true | ||
| 68 | @Environment(\.dismiss) private var dismiss | 69 | @Environment(\.dismiss) private var dismiss |
| 69 | @State private var alerts = AuroraAlerts.shared | 70 | @State private var alerts = AuroraAlerts.shared |
| 70 | @AppStorage(SettingsKey.dailySummary) private var dailySummary = false | 71 | @AppStorage(SettingsKey.dailySummary) private var dailySummary = false |
| @@ -137,6 +138,11 @@ struct SettingsScreen: View { | |||
| 137 | } | 138 | } |
| 138 | Section("About") { | 139 | Section("About") { |
| 139 | LabeledContent("Version", value: Bundle.main.versionString) | 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 | } | ||
| 140 | NavigationLink("Credits and licences") { CreditsScreen() } | 146 | NavigationLink("Credits and licences") { CreditsScreen() } |
| 141 | Link("Privacy policy", destination: privacyPolicyURL) | 147 | Link("Privacy policy", destination: privacyPolicyURL) |
| 142 | } | 148 | } |
App/Sources/SkyScreen.swift +66 −50
| @@ -23,6 +23,7 @@ struct SkyScreen: View { | |||
| 23 | @State private var showTours = false | 23 | @State private var showTours = false |
| 24 | @State private var tour: Tour? | 24 | @State private var tour: Tour? |
| 25 | @State private var tourStep = 0 | 25 | @State private var tourStep = 0 |
| 26 | @State private var turning: Task<Void, Never>? | ||
| 26 | @Environment(\.scenePhase) private var scenePhase | 27 | @Environment(\.scenePhase) private var scenePhase |
| 27 | @State private var libraryPage: LibraryPage? | 28 | @State private var libraryPage: LibraryPage? |
| 28 | @State private var selection: SkyObject? | 29 | @State private var selection: SkyObject? |
| @@ -41,7 +42,7 @@ struct SkyScreen: View { | |||
| 41 | style.background.ignoresSafeArea() | 42 | style.background.ignoresSafeArea() |
| 42 | if let scene = model.scene { | 43 | if let scene = model.scene { |
| 43 | let view = current(width: geo.size.width) | 44 | let view = current(width: geo.size.width) |
| 44 | SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }) { [stats] d in | 45 | SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: tour.flatMap { highlight($0.steps[tourStep].target) }) { [stats] d in |
| 45 | stats.record(d) | 46 | stats.record(d) |
| 46 | } | 47 | } |
| 47 | .ignoresSafeArea() | 48 | .ignoresSafeArea() |
| @@ -59,6 +60,16 @@ struct SkyScreen: View { | |||
| 59 | } | 60 | } |
| 60 | VStack(spacing: 0) { | 61 | VStack(spacing: 0) { |
| 61 | header | 62 | header |
| 63 | if pointer.active { | ||
| 64 | Button { pointer.stop() } label: { | ||
| 65 | Label("Following your phone · Stop", systemImage: "iphone.gen3.radiowaves.left.and.right") | ||
| 66 | .font(.caption.weight(.medium)) | ||
| 67 | .padding(.horizontal, 12).padding(.vertical, 8) | ||
| 68 | .glassEffect(in: .capsule) | ||
| 69 | } | ||
| 70 | .buttonStyle(.plain) | ||
| 71 | .padding(.top, 4) | ||
| 72 | } | ||
| 62 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } | 73 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } |
| 63 | Spacer() | 74 | Spacer() |
| 64 | if let tour { | 75 | if let tour { |
| @@ -81,14 +92,13 @@ struct SkyScreen: View { | |||
| 81 | .sheet(isPresented: $showEvents) { | 92 | .sheet(isPresented: $showEvents) { |
| 82 | EventsScreen(observer: model.observer, placeName: model.placeName) | 93 | EventsScreen(observer: model.observer, placeName: model.placeName) |
| 83 | .preferredColorScheme(.dark) | 94 | .preferredColorScheme(.dark) |
| 84 | .tint(.blue) | ||
| 85 | } | 95 | } |
| 86 | .sheet(isPresented: $showTours) { | 96 | .sheet(isPresented: $showTours) { |
| 87 | TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue) | 97 | TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue) |
| 88 | } | 98 | } |
| 89 | .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } } | 99 | .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } } |
| 90 | .sheet(isPresented: $showYear) { | 100 | .sheet(isPresented: $showYear) { |
| 91 | YearSheet(year: $model.historicalYear).preferredColorScheme(.dark).tint(.blue) | 101 | YearSheet(year: $model.historicalYear, south: model.observer.latitude < 0).preferredColorScheme(.dark) |
| 92 | } | 102 | } |
| 93 | .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) { | 103 | .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) { |
| 94 | SettingsScreen { model.applyLocationSettings() } | 104 | SettingsScreen { model.applyLocationSettings() } |
| @@ -135,14 +145,33 @@ struct SkyScreen: View { | |||
| 135 | return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az) | 145 | return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az) |
| 136 | } | 146 | } |
| 137 | 147 | ||
| 138 | /// Turns the view to a tour target, keeping it at least 10° up so the horizon stays in view. | 148 | private func highlight(_ t: Tour.Target) -> SkyHighlight? { |
| 149 | if let c = t.constellation { return .constellation(c) } | ||
| 150 | if let hr = t.hr { return .star(hr) } | ||
| 151 | if let b = t.body.flatMap(SkyBody.init(rawValue:)) { return .body(b) } | ||
| 152 | return nil | ||
| 153 | } | ||
| 154 | |||
| 155 | /// Pans the view to a tour target over a second, the short way round, keeping it at least | ||
| 156 | /// 10° up so the horizon stays in view. The Canvas redraws each frame, so the turn is stepped by hand. | ||
| 139 | private func aim(at t: Tour.Target) { | 157 | private func aim(at t: Tour.Target) { |
| 140 | guard let h = tourTarget(t) else { return } | 158 | guard let h = tourTarget(t) else { return } |
| 141 | pointer.stop() | 159 | pointer.stop() |
| 142 | withAnimation(.easeInOut(duration: 0.6)) { | 160 | turning?.cancel() |
| 143 | azimuth = h.azimuth | 161 | let from = (az: azimuth, alt: altitude, fov: fieldOfView) |
| 144 | altitude = max(h.altitude, 10) | 162 | let dAz = (h.azimuth - from.az + 540).truncatingRemainder(dividingBy: 360) - 180 |
| 145 | fieldOfView = 80 | 163 | let to = (alt: max(h.altitude, 10), fov: 80.0) |
| 164 | turning = Task { | ||
| 165 | let frames = 60 | ||
| 166 | for f in 1...frames { | ||
| 167 | try? await Task.sleep(for: .milliseconds(16)) | ||
| 168 | if Task.isCancelled { return } | ||
| 169 | let x = Double(f) / Double(frames) | ||
| 170 | let e = x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2 | ||
| 171 | azimuth = (from.az + dAz * e + 360).truncatingRemainder(dividingBy: 360) | ||
| 172 | altitude = from.alt + (to.alt - from.alt) * e | ||
| 173 | fieldOfView = from.fov + (to.fov - from.fov) * e | ||
| 174 | } | ||
| 146 | } | 175 | } |
| 147 | } | 176 | } |
| 148 | 177 | ||
| @@ -170,6 +199,7 @@ struct SkyScreen: View { | |||
| 170 | private func panGesture(width: CGFloat) -> some Gesture { | 199 | private func panGesture(width: CGFloat) -> some Gesture { |
| 171 | DragGesture(minimumDistance: 6) | 200 | DragGesture(minimumDistance: 6) |
| 172 | .onChanged { _ in | 201 | .onChanged { _ in |
| 202 | turning?.cancel() | ||
| 173 | if pointer.active { | 203 | if pointer.active { |
| 174 | azimuth = pointer.direction.azimuth | 204 | azimuth = pointer.direction.azimuth |
| 175 | altitude = min(max(pointer.direction.altitude, 0), 90) | 205 | altitude = min(max(pointer.direction.altitude, 0), 90) |
| @@ -213,51 +243,34 @@ struct SkyScreen: View { | |||
| 213 | #endif | 243 | #endif |
| 214 | } | 244 | } |
| 215 | Spacer() | 245 | Spacer() |
| 216 | if pointer.available { | ||
| 217 | Button { | ||
| 218 | pointer.active ? pointer.stop() : pointer.start() | ||
| 219 | } label: { | ||
| 220 | Image(systemName: pointer.active ? "scope" : "hand.draw") | ||
| 221 | .font(.body) | ||
| 222 | .frame(width: 44, height: 44) | ||
| 223 | } | ||
| 224 | .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") | ||
| 225 | } | ||
| 226 | Menu { | 246 | Menu { |
| 227 | Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") } | 247 | Section("Tonight") { |
| 228 | Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") } | 248 | Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") } |
| 229 | } label: { | 249 | Button { showEvents = true } label: { Label("Events and eclipses", systemImage: "calendar") } |
| 230 | Image(systemName: "ellipsis.circle") | 250 | if pointer.available { |
| 231 | .font(.body) | 251 | Button { pointer.active ? pointer.stop() : pointer.start() } label: { |
| 232 | .frame(width: 44, height: 44) | 252 | Label(pointer.active ? "Stop following the phone" : "Point the phone at the sky", systemImage: "iphone.gen3.radiowaves.left.and.right") |
| 233 | } | 253 | } |
| 234 | .accessibilityLabel("More") | 254 | } |
| 235 | Button { showLog = true } label: { | 255 | } |
| 236 | Image(systemName: "book.closed") | 256 | Section("Explore") { |
| 237 | .font(.body) | 257 | Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") } |
| 238 | .frame(width: 44, height: 44) | 258 | Button { showLog = true } label: { Label("Observing log", systemImage: "book.closed") } |
| 239 | } | 259 | } |
| 240 | .accessibilityLabel("Observing log") | 260 | Section { |
| 241 | Button { showEvents = true } label: { | 261 | Button { nightMode = night ? "off" : "on" } label: { |
| 242 | Image(systemName: "calendar") | 262 | Label(night ? "Turn off night mode" : "Night mode (red light)", systemImage: night ? "eye.slash" : "eye") |
| 243 | .font(.body) | 263 | } |
| 244 | .frame(width: 44, height: 44) | 264 | Button { showSettings = true } label: { Label("Settings", systemImage: "gearshape") } |
| 245 | } | 265 | } |
| 246 | .accessibilityLabel("Events") | ||
| 247 | Button { showSettings = true } label: { | ||
| 248 | Image(systemName: "gearshape") | ||
| 249 | .font(.body) | ||
| 250 | .frame(width: 44, height: 44) | ||
| 251 | } | ||
| 252 | .accessibilityLabel("Settings") | ||
| 253 | Button { | ||
| 254 | nightMode = night ? "off" : "on" | ||
| 255 | } label: { | 266 | } label: { |
| 256 | Image(systemName: night ? "eye.fill" : "eye") | 267 | Label("Explore", systemImage: "sparkle.magnifyingglass") |
| 257 | .font(.body) | 268 | .font(.subheadline.weight(.medium)) |
| 258 | .frame(width: 44, height: 44) | 269 | .padding(.horizontal, 14) |
| 270 | .frame(minHeight: 44) | ||
| 271 | .glassEffect(in: .capsule) | ||
| 259 | } | 272 | } |
| 260 | .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode") | 273 | .accessibilityHint("Tours, events, night mode, settings and more") |
| 261 | } | 274 | } |
| 262 | .padding(.horizontal, 20) | 275 | .padding(.horizontal, 20) |
| 263 | .padding(.top, 8) | 276 | .padding(.top, 8) |
| @@ -378,3 +391,6 @@ final class DrawStats: @unchecked Sendable { | |||
| 378 | return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b) | 391 | return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b) |
| 379 | } | 392 | } |
| 380 | } | 393 | } |
| 394 | |||
| 395 | /// SkyEngine's Body, named so it isn't shadowed by View.Body. | ||
| 396 | private typealias SkyBody = Body | ||
App/Sources/SunScreen.swift +8 −5
| @@ -20,7 +20,9 @@ struct SunScreen: View { | |||
| 20 | ScrollView { | 20 | ScrollView { |
| 21 | VStack(alignment: .leading, spacing: 0) { | 21 | VStack(alignment: .leading, spacing: 0) { |
| 22 | Text("Sun").font(.title.weight(.medium)) | 22 | Text("Sun").font(.title.weight(.medium)) |
| 23 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) | 23 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 24 | Text("The Sun as NASA's Solar Dynamics Observatory sees it now, and the space weather it sends our way: flares, magnetic storms and the aurora.") | ||
| 25 | .font(.subheadline).dimmed(0.75).padding(.bottom, 16) | ||
| 24 | 26 | ||
| 25 | image | 27 | image |
| 26 | Picker("Wavelength", selection: $wavelength) { | 28 | Picker("Wavelength", selection: $wavelength) { |
| @@ -31,9 +33,9 @@ struct SunScreen: View { | |||
| 31 | Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8) | 33 | Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8) |
| 32 | Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4) | 34 | Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4) |
| 33 | 35 | ||
| 34 | section("Aurora") { auroraMap } | 36 | section("Aurora tonight", note: "NOAA's forecast of where the aurora may be overhead in the next hour; brighter green is more likely. From a few hundred kilometres further from the pole it can still be seen low on the horizon.") { auroraMap } |
| 35 | section("Solar flares, last 24 h") { flares } | 37 | section("Solar flares, last 24 h", note: "X-rays from the Sun, measured by NOAA's GOES satellites. Spikes are flares: M and X class can disrupt radio and GPS, and the biggest can bring a storm a day or two later.") { flares } |
| 36 | section("Geomagnetic activity (Kp)") { kpChart } | 38 | section("Geomagnetic activity (Kp)", note: "How disturbed Earth's magnetic field is, from 0 (quiet) to 9. At 5 or more it is a storm and the aurora reaches lower latitudes.") { kpChart } |
| 37 | 39 | ||
| 38 | if failed { | 40 | if failed { |
| 39 | Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12) | 41 | Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12) |
| @@ -74,9 +76,10 @@ struct SunScreen: View { | |||
| 74 | .accessibilityLabel("The Sun at \(wavelength.name)") | 76 | .accessibilityLabel("The Sun at \(wavelength.name)") |
| 75 | } | 77 | } |
| 76 | 78 | ||
| 77 | private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { | 79 | private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View { |
| 78 | VStack(alignment: .leading, spacing: 10) { | 80 | VStack(alignment: .leading, spacing: 10) { |
| 79 | Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) | 81 | Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 82 | if let note { Text(note).font(.footnote).dimmed(0.75) } | ||
| 80 | content() | 83 | content() |
| 81 | } | 84 | } |
| 82 | .padding(.top, 24) | 85 | .padding(.top, 24) |
App/Sources/SystemScreen.swift +6 −3
| @@ -24,7 +24,9 @@ struct SystemScreen: View { | |||
| 24 | ScrollView { | 24 | ScrollView { |
| 25 | VStack(alignment: .leading, spacing: 0) { | 25 | VStack(alignment: .leading, spacing: 0) { |
| 26 | Text("Solar system").font(.title.weight(.medium)) | 26 | Text("Solar system").font(.title.weight(.medium)) |
| 27 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) | 27 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 28 | Text("The planets, and the five spacecraft travelling out of the solar system, seen from above. Drag the slider or press play to watch them move between 1958 and 2050.") | ||
| 29 | .font(.subheadline).dimmed(0.75).padding(.bottom, 16) | ||
| 28 | 30 | ||
| 29 | LiveSection(ink: ink, accent: accent) | 31 | LiveSection(ink: ink, accent: accent) |
| 30 | 32 | ||
| @@ -54,7 +56,7 @@ struct SystemScreen: View { | |||
| 54 | Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") | 56 | Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") |
| 55 | .font(.caption2).dimmed(0.6).padding(.top, 8) | 57 | .font(.caption2).dimmed(0.6).padding(.top, 8) |
| 56 | 58 | ||
| 57 | section("Spacecraft") { | 59 | section("Spacecraft", note: "How far each probe is from the Sun, and how long its radio signal takes to reach us. Tap one to read about it.") { |
| 58 | if typeSize.isAccessibilitySize { | 60 | if typeSize.isAccessibilitySize { |
| 59 | VStack(alignment: .leading, spacing: 12) { | 61 | VStack(alignment: .leading, spacing: 12) { |
| 60 | ForEach(Spacecraft.all) { craft in | 62 | ForEach(Spacecraft.all) { craft in |
| @@ -132,9 +134,10 @@ struct SystemScreen: View { | |||
| 132 | return h > 0 ? "\(h) h \(m) m" : "\(m) m" | 134 | return h > 0 ? "\(h) h \(m) m" : "\(m) m" |
| 133 | } | 135 | } |
| 134 | 136 | ||
| 135 | private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { | 137 | private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View { |
| 136 | VStack(alignment: .leading, spacing: 10) { | 138 | VStack(alignment: .leading, spacing: 10) { |
| 137 | Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) | 139 | Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 140 | if let note { Text(note).font(.footnote).dimmed(0.75) } | ||
| 138 | content() | 141 | content() |
| 139 | } | 142 | } |
| 140 | .padding(.top, 24) | 143 | .padding(.top, 24) |
App/Sources/TourViews.swift +5 −3
| @@ -61,14 +61,16 @@ struct TourCard: View { | |||
| 61 | Button("Read more") { onOpen(page) } | 61 | Button("Read more") { onOpen(page) } |
| 62 | Spacer() | 62 | Spacer() |
| 63 | } | 63 | } |
| 64 | Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) } | 64 | if step < tour.steps.count - 1 { |
| 65 | .disabled(step == tour.steps.count - 1) | 65 | Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) } |
| 66 | } | ||
| 66 | } | 67 | } |
| 67 | .font(.subheadline.weight(.medium)) | 68 | .font(.subheadline.weight(.medium)) |
| 68 | .padding(.top, 2) | 69 | .padding(.top, 2) |
| 69 | } | 70 | } |
| 70 | .padding(16) | 71 | .padding(16) |
| 71 | .background(background.opacity(0.92), in: RoundedRectangle(cornerRadius: 16)) | 72 | .background(background, in: RoundedRectangle(cornerRadius: 16)) |
| 73 | .overlay(RoundedRectangle(cornerRadius: 16).stroke(ink.opacity(0.15), lineWidth: 0.5)) | ||
| 72 | .foregroundStyle(ink) | 74 | .foregroundStyle(ink) |
| 73 | .padding(.horizontal, 12) | 75 | .padding(.horizontal, 12) |
| 74 | .padding(.bottom, 8) | 76 | .padding(.bottom, 8) |
App/Sources/YearSheet.swift +94 −18
| @@ -3,11 +3,15 @@ import SwiftUI | |||
| 3 | /// Picks a year for the historical sky, from 3000 BCE to 3000 CE. | 3 | /// Picks a year for the historical sky, from 3000 BCE to 3000 CE. |
| 4 | struct YearSheet: View { | 4 | struct YearSheet: View { |
| 5 | @Binding var year: Int? | 5 | @Binding var year: Int? |
| 6 | var south = false | ||
| 6 | @State private var draft: Int | 7 | @State private var draft: Int |
| 7 | @Environment(\.dismiss) private var dismiss | 8 | @Environment(\.dismiss) private var dismiss |
| 8 | 9 | ||
| 9 | static let range = -2999...3000 | 10 | static let range = -2999...3000 |
| 10 | 11 | ||
| 12 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | ||
| 13 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | ||
| 14 | |||
| 11 | private let moments: [(String, Int)] = [ | 15 | private let moments: [(String, Int)] = [ |
| 12 | ("Great Pyramid built, Thuban at the pole", -2559), | 16 | ("Great Pyramid built, Thuban at the pole", -2559), |
| 13 | ("Ptolemy's Almagest", 150), | 17 | ("Ptolemy's Almagest", 150), |
| @@ -17,34 +21,42 @@ struct YearSheet: View { | |||
| 17 | ("Polaris closest to the pole", 2100), | 21 | ("Polaris closest to the pole", 2100), |
| 18 | ] | 22 | ] |
| 19 | 23 | ||
| 20 | init(year: Binding<Int?>) { | 24 | init(year: Binding<Int?>, south: Bool = false) { |
| 21 | _year = year | 25 | _year = year |
| 26 | self.south = south | ||
| 22 | _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now)) | 27 | _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now)) |
| 23 | } | 28 | } |
| 24 | 29 | ||
| 25 | static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" } | 30 | static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" } |
| 26 | 31 | ||
| 32 | private var poleStar: (name: String, degrees: Double)? { | ||
| 33 | SkyModel.poleStar(at: SkyModel.date(.now, inYear: draft), south: south) | ||
| 34 | } | ||
| 35 | |||
| 27 | var body: some View { | 36 | var body: some View { |
| 28 | NavigationStack { | 37 | NavigationStack { |
| 29 | Form { | 38 | ScrollView { |
| 30 | Section { | 39 | VStack(alignment: .leading, spacing: 0) { |
| 31 | Stepper(value: $draft, in: Self.range, step: 100) { | 40 | Text("Sky in another year").font(.title.weight(.medium)) |
| 32 | Text(Self.label(draft)).font(.title2.monospacedDigit()) | 41 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 33 | } | 42 | Text("Tonight's date and time in any year from 3000 BCE to 3000 CE. The stars drift with precession and their own motion.") |
| 34 | Stepper("Year by year", value: $draft, in: Self.range) | 43 | .font(.subheadline).foregroundStyle(ink.opacity(0.75)) |
| 35 | } footer: { | 44 | |
| 36 | Text("Shows the sky for this date and time in another year, with precession and the stars' own motion. Dates before 1582 are in the Julian calendar. Planet positions are approximate far from today.") | 45 | yearPicker.padding(.top, 28) |
| 37 | } | 46 | |
| 38 | Section("Moments") { | 47 | Text("Moments").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8) |
| 39 | ForEach(moments, id: \.1) { m in | 48 | .foregroundStyle(ink.opacity(0.6)).padding(.top, 32).padding(.bottom, 4) |
| 40 | Button { draft = m.1 } label: { | 49 | ForEach(moments, id: \.1) { m in moment(m.0, m.1) } |
| 41 | LabeledContent(m.0, value: Self.label(m.1)) | 50 | |
| 42 | } | 51 | Text("Dates before 1582 are in the Julian calendar. Planet positions far from today are approximate.") |
| 43 | } | 52 | .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.top, 20) |
| 44 | } | 53 | } |
| 54 | .padding(20) | ||
| 55 | .readableWidth() | ||
| 45 | } | 56 | } |
| 46 | .navigationTitle("Sky in another year") | 57 | .background(Color.black.ignoresSafeArea()) |
| 47 | .navigationBarTitleDisplayMode(.inline) | 58 | .foregroundStyle(ink) |
| 59 | .tint(accent) | ||
| 48 | .toolbar { | 60 | .toolbar { |
| 49 | ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } | 61 | ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } |
| 50 | ToolbarItem(placement: .confirmationAction) { | 62 | ToolbarItem(placement: .confirmationAction) { |
| @@ -52,8 +64,72 @@ struct YearSheet: View { | |||
| 52 | year = draft == Calendar.current.component(.year, from: .now) ? nil : draft | 64 | year = draft == Calendar.current.component(.year, from: .now) ? nil : draft |
| 53 | dismiss() | 65 | dismiss() |
| 54 | } | 66 | } |
| 67 | .fontWeight(.semibold) | ||
| 55 | } | 68 | } |
| 56 | } | 69 | } |
| 57 | } | 70 | } |
| 58 | } | 71 | } |
| 72 | |||
| 73 | private var yearPicker: some View { | ||
| 74 | VStack(spacing: 14) { | ||
| 75 | VStack(spacing: 2) { | ||
| 76 | Text(String(draft > 0 ? draft : 1 - draft)) | ||
| 77 | .font(.system(size: 64, weight: .light).monospacedDigit()) | ||
| 78 | .contentTransition(.numericText()) | ||
| 79 | Text(draft > 0 ? "CE" : "BCE").font(.subheadline.weight(.medium)).tracking(2).foregroundStyle(ink.opacity(0.7)) | ||
| 80 | } | ||
| 81 | .frame(maxWidth: .infinity) | ||
| 82 | .accessibilityElement(children: .combine) | ||
| 83 | .accessibilityLabel(Self.label(draft)) | ||
| 84 | |||
| 85 | if let p = poleStar { | ||
| 86 | Text(String(format: "Pole star: %@, %.1f° from the pole", p.name, p.degrees)) | ||
| 87 | .font(.footnote).foregroundStyle(ink.opacity(0.75)) | ||
| 88 | } | ||
| 89 | |||
| 90 | Slider(value: Binding(get: { Double(draft) }, set: { draft = Int($0.rounded()) }), in: Double(Self.range.lowerBound)...Double(Self.range.upperBound)) | ||
| 91 | .accessibilityLabel("Year") | ||
| 92 | .accessibilityValue(Self.label(draft)) | ||
| 93 | HStack { | ||
| 94 | Text("3000 BCE"); Spacer(); Text("1 CE"); Spacer(); Text("3000 CE") | ||
| 95 | } | ||
| 96 | .font(.caption2).foregroundStyle(ink.opacity(0.5)) | ||
| 97 | .accessibilityHidden(true) | ||
| 98 | |||
| 99 | HStack(spacing: 8) { | ||
| 100 | step("−100", -100); step("−1", -1); step("+1", 1); step("+100", 100) | ||
| 101 | } | ||
| 102 | .padding(.top, 4) | ||
| 103 | } | ||
| 104 | .padding(20) | ||
| 105 | .background(ink.opacity(0.06), in: RoundedRectangle(cornerRadius: 20)) | ||
| 106 | .animation(.snappy, value: draft) | ||
| 107 | } | ||
| 108 | |||
| 109 | private func step(_ title: String, _ by: Int) -> some View { | ||
| 110 | Button { draft = min(max(draft + by, Self.range.lowerBound), Self.range.upperBound) } label: { | ||
| 111 | Text(title).font(.subheadline.weight(.medium).monospacedDigit()) | ||
| 112 | .frame(maxWidth: .infinity, minHeight: 40) | ||
| 113 | .background(ink.opacity(0.08), in: Capsule()) | ||
| 114 | } | ||
| 115 | .buttonStyle(.plain) | ||
| 116 | .accessibilityLabel(by > 0 ? "\(by) years later" : "\(-by) years earlier") | ||
| 117 | } | ||
| 118 | |||
| 119 | private func moment(_ title: String, _ y: Int) -> some View { | ||
| 120 | Button { draft = y } label: { | ||
| 121 | HStack(alignment: .firstTextBaseline, spacing: 14) { | ||
| 122 | Text(Self.label(y)).font(.subheadline.weight(.medium).monospacedDigit()) | ||
| 123 | .foregroundStyle(draft == y ? accent : ink.opacity(0.7)) | ||
| 124 | .frame(width: 84, alignment: .leading) | ||
| 125 | Text(title).font(.subheadline) | ||
| 126 | Spacer(minLength: 0) | ||
| 127 | if draft == y { Image(systemName: "checkmark").font(.footnote.weight(.semibold)).foregroundStyle(accent) } | ||
| 128 | } | ||
| 129 | .padding(.vertical, 12) | ||
| 130 | .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) } | ||
| 131 | .contentShape(Rectangle()) | ||
| 132 | } | ||
| 133 | .buttonStyle(.plain) | ||
| 134 | } | ||
| 59 | } | 135 | } |
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +43 −1
| @@ -40,6 +40,8 @@ public struct SkyStyle: Sendable { | |||
| 40 | public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) | 40 | public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) |
| 41 | public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27) | 41 | public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27) |
| 42 | public var planet = Color(red: 0.95, green: 0.84, blue: 0.60) | 42 | public var planet = Color(red: 0.95, green: 0.84, blue: 0.60) |
| 43 | /// Rings and lines for whatever a tour is pointing at. | ||
| 44 | public var highlight = Color(red: 1.0, green: 0.78, blue: 0.35) | ||
| 43 | /// Draw stars in a single red, for dark-adapted eyes. | 45 | /// Draw stars in a single red, for dark-adapted eyes. |
| 44 | public var monochromeStars: Color? | 46 | public var monochromeStars: Color? |
| 45 | 47 | ||
| @@ -58,6 +60,7 @@ public struct SkyStyle: Sendable { | |||
| 58 | s.label = red | 60 | s.label = red |
| 59 | s.muted = red.opacity(0.9) | 61 | s.muted = red.opacity(0.9) |
| 60 | s.moonLight = red | 62 | s.moonLight = red |
| 63 | s.highlight = red | ||
| 61 | s.moonDark = Color(red: 0.12, green: 0, blue: 0) | 64 | s.moonDark = Color(red: 0.12, green: 0, blue: 0) |
| 62 | s.planet = red | 65 | s.planet = red |
| 63 | s.monochromeStars = red | 66 | s.monochromeStars = red |
| @@ -72,6 +75,15 @@ public struct SkyStyle: Sendable { | |||
| 72 | } | 75 | } |
| 73 | } | 76 | } |
| 74 | 77 | ||
| 78 | /// Something to draw attention to, e.g. the current stop of a guided tour. | ||
| 79 | public enum SkyHighlight: Sendable, Equatable { | ||
| 80 | /// IAU abbreviation. | ||
| 81 | case constellation(String) | ||
| 82 | /// Bright Star Catalogue HR number. | ||
| 83 | case star(Int) | ||
| 84 | case body(Body) | ||
| 85 | } | ||
| 86 | |||
| 75 | public struct SkyView: View { | 87 | public struct SkyView: View { |
| 76 | public var scene: SkyScene | 88 | public var scene: SkyScene |
| 77 | public var centerAzimuth: Double | 89 | public var centerAzimuth: Double |
| @@ -80,13 +92,15 @@ public struct SkyView: View { | |||
| 80 | public var style: SkyStyle | 92 | public var style: SkyStyle |
| 81 | /// Figures to draw instead of the IAU constellation lines. | 93 | /// Figures to draw instead of the IAU constellation lines. |
| 82 | public var culture: SkyCulture? | 94 | public var culture: SkyCulture? |
| 95 | public var highlight: SkyHighlight? | ||
| 83 | /// Called after each draw with the time the draw took. | 96 | /// Called after each draw with the time the draw took. |
| 84 | public var onDraw: (@Sendable (Duration) -> Void)? | 97 | public var onDraw: (@Sendable (Duration) -> Void)? |
| 85 | 98 | ||
| 86 | public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, onDraw: (@Sendable (Duration) -> Void)? = nil) { | 99 | public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, highlight: SkyHighlight? = nil, onDraw: (@Sendable (Duration) -> Void)? = nil) { |
| 87 | self.scene = scene | 100 | self.scene = scene |
| 88 | self.style = style | 101 | self.style = style |
| 89 | self.culture = culture | 102 | self.culture = culture |
| 103 | self.highlight = highlight | ||
| 90 | self.centerAzimuth = centerAzimuth | 104 | self.centerAzimuth = centerAzimuth |
| 91 | self.centerAltitude = centerAltitude | 105 | self.centerAltitude = centerAltitude |
| 92 | self.fieldOfView = fieldOfView | 106 | self.fieldOfView = fieldOfView |
| @@ -101,6 +115,7 @@ public struct SkyView: View { | |||
| 101 | drawConstellations(&context, p) | 115 | drawConstellations(&context, p) |
| 102 | drawStars(&context, p) | 116 | drawStars(&context, p) |
| 103 | drawBodies(&context, p) | 117 | drawBodies(&context, p) |
| 118 | if let highlight { drawHighlight(highlight, &context, p) } | ||
| 104 | drawHorizon(&context, p, size: size) | 119 | drawHorizon(&context, p, size: size) |
| 105 | onDraw?(ContinuousClock.now - start) | 120 | onDraw?(ContinuousClock.now - start) |
| 106 | } | 121 | } |
| @@ -151,6 +166,33 @@ public struct SkyView: View { | |||
| 151 | context.stroke(path, with: .color(style.line), lineWidth: 0.6) | 166 | context.stroke(path, with: .color(style.line), lineWidth: 0.6) |
| 152 | } | 167 | } |
| 153 | 168 | ||
| 169 | /// A constellation's IAU lines drawn bright and thick; a star or body ringed. | ||
| 170 | func drawHighlight(_ h: SkyHighlight, _ context: inout GraphicsContext, _ p: Projection) { | ||
| 171 | func ring(_ position: Horizontal?, radius: Double) { | ||
| 172 | guard let position, let pt = p.point(position) else { return } | ||
| 173 | context.stroke(Path(ellipseIn: CGRect(x: pt.x - radius, y: pt.y - radius, width: 2 * radius, height: 2 * radius)), | ||
| 174 | with: .color(style.highlight), lineWidth: 2) | ||
| 175 | } | ||
| 176 | switch h { | ||
| 177 | case .star(let hr): | ||
| 178 | ring(scene.starsByHR[hr], radius: 14) | ||
| 179 | case .body(let body): | ||
| 180 | ring(scene.bodies.first { $0.body == body }?.position, radius: 18) | ||
| 181 | case .constellation(let id): | ||
| 182 | guard let figure = Catalog.constellations.first(where: { $0.id == id }) else { return } | ||
| 183 | var path = Path() | ||
| 184 | for line in figure.lines { | ||
| 185 | for (a, b) in zip(line, line.dropFirst()) { | ||
| 186 | guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b], | ||
| 187 | let pa = p.point(ha), let pb = p.point(hb) else { continue } | ||
| 188 | path.move(to: pa) | ||
| 189 | path.addLine(to: pb) | ||
| 190 | } | ||
| 191 | } | ||
| 192 | context.stroke(path, with: .color(style.highlight.opacity(0.9)), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 154 | func drawStars(_ context: inout GraphicsContext, _ p: Projection) { | 196 | func drawStars(_ context: inout GraphicsContext, _ p: Projection) { |
| 155 | for s in scene.stars where s.position.altitude > 0 { | 197 | for s in scene.stars where s.position.altitude > 0 { |
| 156 | guard let pt = p.point(s.position) else { continue } | 198 | guard let pt = p.point(s.position) else { continue } |