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

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/App/Sources/TourViews.swift history · blame · raw

128 lines · 5713 bytes

  1import Library
  2import SkyEngine
  3import SwiftUI
  4
  5/// The tours, starting with the one for this month.
  6struct TourPicker: View {
  7    var tonight: Tonight?
  8    var onPick: (Tour) -> Void
  9    @Environment(\.dismiss) private var dismiss
 10
 11    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 12    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 13    private let month = Calendar.current.component(.month, from: .now)
 14
 15    var body: some View {
 16        NavigationStack {
 17            ScrollView {
 18                VStack(alignment: .leading, spacing: 0) {
 19                    Text("Guided tours").font(.title.weight(.medium))
 20                    Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
 21                    Text("A walk around the evening sky, one sight at a time. The view turns to each stop and marks what to look for. Best outside after dark, but they work anywhere.")
 22                        .font(.subheadline).foregroundStyle(ink.opacity(0.75))
 23                        .padding(.bottom, 20)
 24                    if let tonight, tonight.moonlit {
 25                        Text("Tonight: \(tonight.moonAndDarkness()) Fainter stars are harder to see while the Moon is up.")
 26                            .font(.footnote).foregroundStyle(ink.opacity(0.75))
 27                            .padding(.bottom, 20)
 28                    }
 29                    ForEach(Tour.ordered(month: month)) { tour in card(tour) }
 30                }
 31                .padding(20)
 32                .readableWidth()
 33            }
 34            .background(Color.black.ignoresSafeArea())
 35            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
 36        }
 37        .foregroundStyle(ink)
 38        .tint(accent)
 39    }
 40
 41    private func card(_ tour: Tour) -> some View {
 42        let now = tour.months.contains(month)
 43        return Button {
 44            onPick(tour)
 45            dismiss()
 46        } label: {
 47            VStack(alignment: .leading, spacing: 10) {
 48                HStack {
 49                    if now {
 50                        Text("THIS MONTH").font(.caption2.weight(.bold)).tracking(0.8)
 51                            .padding(.horizontal, 6).padding(.vertical, 2)
 52                            .background(accent, in: RoundedRectangle(cornerRadius: 3))
 53                    }
 54                    Text(tour.title).font(.caption.weight(.medium)).foregroundStyle(ink.opacity(0.7))
 55                    Spacer()
 56                    Text("\(tour.steps.count) stops").font(.caption).foregroundStyle(ink.opacity(0.6))
 57                }
 58                Text(tour.steps.prefix(4).map(\.title).joined(separator: " · ") + (tour.steps.count > 4 ? " …" : ""))
 59                    .font(.title3.weight(.medium))
 60                    .multilineTextAlignment(.leading)
 61                Text(tour.intro).font(.footnote).foregroundStyle(ink.opacity(0.75)).multilineTextAlignment(.leading)
 62                HStack(spacing: 6) {
 63                    Text("Start tour").font(.subheadline.weight(.semibold))
 64                    Image(systemName: "arrow.right").font(.footnote.weight(.semibold))
 65                }
 66                .foregroundStyle(now ? accent : ink.opacity(0.8))
 67                .padding(.top, 2)
 68            }
 69            .padding(18)
 70            .frame(maxWidth: .infinity, alignment: .leading)
 71            .background(ink.opacity(now ? 0.09 : 0.05), in: RoundedRectangle(cornerRadius: 18))
 72            .overlay(RoundedRectangle(cornerRadius: 18).stroke(now ? accent.opacity(0.6) : ink.opacity(0.1), lineWidth: now ? 1 : 0.5))
 73            .contentShape(RoundedRectangle(cornerRadius: 18))
 74        }
 75        .buttonStyle(.plain)
 76        .padding(.bottom, 14)
 77        .accessibilityHint("Starts the tour")
 78    }
 79}
 80
 81/// One stop of a tour, with back, next and a link to the Library page.
 82struct TourCard: View {
 83    var tour: Tour
 84    @Binding var step: Int
 85    /// Whether the target is below the horizon at the time shown.
 86    var belowHorizon: Bool
 87    var ink: Color
 88    var background: Color
 89    var onOpen: (LibraryPage) -> Void
 90    var onEnd: () -> Void
 91
 92    var body: some View {
 93        let s = tour.steps[step]
 94        VStack(alignment: .leading, spacing: 8) {
 95            HStack {
 96                Text("\(tour.title) · \(step + 1) of \(tour.steps.count)").font(.caption2).dimmed(0.7)
 97                Spacer()
 98                Button("End tour", action: onEnd).font(.caption.weight(.medium))
 99            }
100            Text(s.title).font(.headline)
101            Text(s.text).font(.subheadline).fixedSize(horizontal: false, vertical: true)
102            if belowHorizon {
103                Text("Below your horizon at this time. Try later in the evening or another month.").font(.footnote).dimmed(0.7)
104            }
105            HStack {
106                if step > 0 {
107                    Button { step -= 1 } label: { Label("Back", systemImage: "chevron.left") }
108                }
109                Spacer()
110                if let page = s.libraryPage {
111                    Button("Read more") { onOpen(page) }
112                    Spacer()
113                }
114                if step < tour.steps.count - 1 {
115                    Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) }
116                }
117            }
118            .font(.subheadline.weight(.medium))
119            .padding(.top, 2)
120        }
121        .padding(16)
122        .background(background, in: RoundedRectangle(cornerRadius: 16))
123        .overlay(RoundedRectangle(cornerRadius: 16).stroke(ink.opacity(0.15), lineWidth: 0.5))
124        .foregroundStyle(ink)
125        .padding(.horizontal, 12)
126        .padding(.bottom, 8)
127    }
128}