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

v1.0.0
moonrise/App/Sources/EventsScreen.swift history · blame · raw

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}