App/Sources/TourViews.swift
121 lines · 5340 bytes
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}