App/Sources/EventsScreen.swift
126 lines · 6258 bytes
1import SkyEngine
2import SwiftUI
3
4/// The next year of eclipses, oppositions, elongations, meteor showers, moon phases and seasons.
5struct EventsScreen: View {
6 var observer: Observer
7 var placeName: String
8 @State private var events: [SkyEvent] = []
9 @State private var filter = "notable"
10 @State private var eclipse: SkyEvent?
11 @Environment(\.dismiss) private var dismiss
12
13 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
14 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
15
16 private var shown: [SkyEvent] {
17 filter == "all" ? events : events.filter { $0.kind != .moonPhase && $0.kind != .season }
18 }
19
20 private var months: [(String, [SkyEvent])] {
21 let groups = Dictionary(grouping: shown) { $0.date.formatted(.dateTime.year().month(.wide)) }
22 return groups.sorted { $0.value[0].date < $1.value[0].date }.map { ($0.key, $0.value) }
23 }
24
25 var body: some View {
26 NavigationStack {
27 ScrollView {
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))
33 Picker("Show", selection: $filter) {
34 Text("Highlights").tag("notable")
35 Text("Everything").tag("all")
36 }
37 .pickerStyle(.segmented)
38 .padding(.vertical, 16)
39
40 if events.isEmpty {
41 ProgressView().tint(ink).frame(maxWidth: .infinity).padding(.top, 40)
42 }
43 ForEach(months, id: \.0) { month, items in
44 Section {
45 ForEach(items) { e in
46 if e.kind == .solarEclipse {
47 Button { eclipse = e } label: { row(e, link: true) }
48 .buttonStyle(.plain)
49 .accessibilityHint("Shows the path and local times")
50 } else {
51 row(e, link: false)
52 }
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)
60 }
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)
64 }
65 .padding(.horizontal, 20)
66 .readableWidth()
67 }
68 .background(Color.black.ignoresSafeArea())
69 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
70 .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) }
71 }
72 .foregroundStyle(ink)
73 .tint(accent)
74 .task {
75 let observer = observer
76 events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value
77 }
78 }
79
80 private func row(_ e: SkyEvent, link: Bool) -> some View {
81 HStack(alignment: .top, spacing: 14) {
82 VStack(spacing: 0) {
83 Text(e.date.formatted(.dateTime.day())).font(.title2.weight(.medium).monospacedDigit())
84 Text(e.date.formatted(.dateTime.weekday(.abbreviated))).font(.caption2).textCase(.uppercase).foregroundStyle(ink.opacity(0.6))
85 }
86 .frame(width: 40)
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)) }
95 if let v = e.visibleHere {
96 HStack(spacing: 5) {
97 Circle().fill(v ? accent : ink.opacity(0.35)).frame(width: 6, height: 6)
98 Text(v ? "Visible from here" : "Not visible from here").font(.caption.weight(.medium))
99 .foregroundStyle(v ? ink : ink.opacity(0.6))
100 }
101 .padding(.top, 2)
102 }
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 }
108 }
109 .padding(.vertical, 12)
110 .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) }
111 .contentShape(Rectangle())
112 .accessibilityElement(children: .combine)
113 }
114
115 private func symbol(_ k: SkyEvent.Kind) -> String {
116 switch k {
117 case .moonPhase: "moonphase.full.moon"
118 case .lunarEclipse: "moon.circle"
119 case .solarEclipse: "sun.max.circle"
120 case .opposition: "circle.and.line.horizontal"
121 case .elongation: "arrow.left.and.right"
122 case .season: "calendar"
123 case .meteorShower: "sparkles"
124 }
125 }
126}