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