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

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

121 lines · 5340 bytes

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