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

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Sources/ProgramsScreen.swift history · blame · raw

136 lines · 6133 bytes

6 symbols in this file
  1import ObservingLog
  2import SkyEngine
  3import SwiftUI
  4
  5/// Observing programs: the bundled Messier list plus Org files in Documents/Observations/Programs.
  6struct ProgramsScreen: View {
  7    var newEntry: () -> Observation
  8    @State private var programs: [ObservingProgram] = []
  9    @State private var log: [Observation] = []
 10
 11    var body: some View {
 12        let resolver = CatalogResolver(programs: programs)
 13        List {
 14            Section {
 15                ForEach(programs) { p in
 16                    let n = ProgramProgress(program: p, log: log, resolver: resolver).count
 17                    NavigationLink { ProgramDetail(program: p, newEntry: newEntry) } label: {
 18                        VStack(alignment: .leading, spacing: 6) {
 19                            HStack {
 20                                Text(p.title)
 21                                Spacer()
 22                                Text("\(n) / \(p.objects.count)").monospacedDigit().foregroundStyle(.secondary)
 23                            }
 24                            ProgressView(value: Double(n), total: Double(max(p.objects.count, 1)))
 25                                .accessibilityHidden(true)
 26                        }
 27                    }
 28                    .accessibilityLabel("\(p.title), \(n) of \(p.objects.count) logged")
 29                }
 30            } footer: {
 31                Text("Add your own lists as Org files in Files › On My iPhone › Moonrise › Observations › Programs: a #+TITLE: line, then one top-level heading per object, such as * M13 or * NGC 869. A :CATALOG: property sets an object's IDs, and :RA: and :DEC: in degrees its position.")
 32            }
 33        }
 34        .navigationTitle("Programs")
 35        .navigationBarTitleDisplayMode(.inline)
 36        .onAppear {
 37            try? FileManager.default.createDirectory(at: observingLog.programsFolder, withIntermediateDirectories: true)
 38            programs = observingLog.programs()
 39            log = observingLog.all()
 40        }
 41    }
 42}
 43
 44struct ProgramDetail: View {
 45    struct Candidate: Sendable {
 46        var object: ObservingProgram.Object
 47        var best: Date
 48        var position: Horizontal
 49    }
 50
 51    var program: ObservingProgram
 52    var newEntry: () -> Observation
 53    @State private var progress: ProgramProgress?
 54    @State private var upTonight: [Candidate]?
 55    @State private var editing: Observation?
 56
 57    var body: some View {
 58        List {
 59            Section {
 60                if let upTonight {
 61                    if upTonight.isEmpty {
 62                        Text("None of the objects still to log gets 25° up in full darkness tonight.").font(.footnote).foregroundStyle(.secondary)
 63                    }
 64                    ForEach(Array(upTonight.enumerated()), id: \.offset) { _, c in
 65                        row(c.object, trailing: "\(c.best.formatted(date: .omitted, time: .shortened)) · \(Int(c.position.altitude.rounded()))° \(SkyEngine.compass(c.position.azimuth))")
 66                    }
 67                } else {
 68                    ProgressView()
 69                }
 70            } header: {
 71                Text("Not yet logged, up tonight")
 72            } footer: {
 73                Text("Best time while the Sun is more than 12° below the horizon.")
 74            }
 75            Section("\(progress?.count ?? 0) of \(program.objects.count) logged") {
 76                ForEach(Array(program.objects.enumerated()), id: \.offset) { _, o in
 77                    row(o, trailing: progress?.first[o.id].map { $0.date.formatted(date: .abbreviated, time: .omitted) } ?? "Log")
 78                }
 79            }
 80        }
 81        .navigationTitle(program.title)
 82        .navigationBarTitleDisplayMode(.inline)
 83        .sheet(item: $editing) { o in
 84            ObservationEditor(observation: o) { reload() }
 85        }
 86        .onAppear(perform: reload)
 87        .task { await findUpTonight() }
 88    }
 89
 90    /// Opens the first entry for a logged object, else a new entry for it.
 91    private func row(_ o: ObservingProgram.Object, trailing: String) -> some View {
 92        let logged = progress?.first[o.id]
 93        return Button {
 94            if let logged {
 95                editing = logged
 96            } else {
 97                var entry = newEntry()
 98                entry.object = o.name
 99                entry.catalog = o.ids
100                editing = entry
101            }
102        } label: {
103            HStack(alignment: .firstTextBaseline) {
104                VStack(alignment: .leading, spacing: 2) {
105                    Text(o.name)
106                    if !o.detail.isEmpty { Text(o.detail).font(.footnote).foregroundStyle(.secondary) }
107                }
108                Spacer()
109                Text(trailing).font(.footnote).monospacedDigit().foregroundStyle(logged == nil ? .primary : .secondary)
110            }
111            .contentShape(.rect)
112        }
113        .buttonStyle(.plain)
114        .accessibilityHint(logged == nil ? "Logs this object" : "Opens the entry")
115    }
116
117    private func reload() {
118        let programs = observingLog.programs()
119        progress = ProgramProgress(program: program, log: observingLog.all(), resolver: CatalogResolver(programs: programs))
120        if let upTonight, let progress { self.upTonight = upTonight.filter { progress.first[$0.object.id] == nil } }
121    }
122
123    private func findUpTonight() async {
124        let here = newEntry()
125        guard let lat = here.latitude, let lon = here.longitude else { upTonight = []; return }
126        let observer = Observer(latitude: lat, longitude: lon)
127        let pending = program.objects.filter { progress?.first[$0.id] == nil && $0.ra != nil && $0.dec != nil }
128        let found = await Task.detached {
129            guard let night = try? Tonight(at: .now, from: observer),
130                  let best = try? SkyEngine.bestInDarkness(j2000: pending.map { ($0.ra!, $0.dec!) }, from: night.start, to: night.end, observer: observer)
131            else { return [Candidate]() }
132            return zip(pending, best).compactMap { o, b in b.map { Candidate(object: o, best: $0.date, position: $0.position) } }
133        }.value
134        upTonight = found.sorted { $0.best < $1.best }
135    }
136}