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

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/App/Sources/LogScreen.swift history · blame · raw

120 lines · 5341 bytes

  1import ObservingLog
  2import SwiftUI
  3
  4/// The observing log lives in Documents/Observations, one Org file per entry, visible in the Files app.
  5let observingLog = ObservingLog(folder: URL.documentsDirectory.appending(path: "Observations"))
  6
  7struct LogScreen: View {
  8    @State private var entries: [Observation] = []
  9    @State private var editing: Observation?
 10    var newEntry: () -> Observation
 11    @Environment(\.dismiss) private var dismiss
 12
 13    var body: some View {
 14        NavigationStack {
 15            List {
 16                if entries.isEmpty {
 17                    Text("No observations yet. Add one here, or use Log on an object's card in the Sky tab.")
 18                        .font(.footnote).foregroundStyle(.secondary)
 19                }
 20                ForEach(entries) { o in
 21                    Button { editing = o } label: {
 22                        VStack(alignment: .leading, spacing: 3) {
 23                            Text(o.object).font(.body)
 24                            Text("\(o.date.formatted(date: .abbreviated, time: .shortened)) · \(o.place)").font(.footnote).foregroundStyle(.secondary)
 25                            if !o.notes.isEmpty { Text(o.notes).font(.footnote).lineLimit(3) }
 26                        }
 27                    }
 28                    .buttonStyle(.plain)
 29                }
 30                .onDelete { idx in
 31                    for i in idx { try? observingLog.delete(id: entries[i].id) }
 32                    reload()
 33                }
 34            }
 35            .navigationTitle("Observing log")
 36            .navigationBarTitleDisplayMode(.inline)
 37            .toolbar {
 38                ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
 39                ToolbarItem(placement: .primaryAction) {
 40                    NavigationLink { EquipmentScreen() } label: { Image(systemName: "binoculars") }
 41                        .accessibilityLabel("Equipment")
 42                }
 43                ToolbarItem(placement: .primaryAction) {
 44                    Button { editing = newEntry() } label: { Image(systemName: "plus") }
 45                        .accessibilityLabel("New observation")
 46                }
 47            }
 48            .safeAreaInset(edge: .bottom) {
 49                Text("Saved as Org files in Files › On My iPhone › Moonrise › Observations.")
 50                    .font(.caption2).foregroundStyle(.secondary).padding(8)
 51            }
 52            .sheet(item: $editing) { o in
 53                ObservationEditor(observation: o) { reload() }
 54            }
 55        }
 56        .onAppear(perform: reload)
 57    }
 58
 59    private func reload() { entries = observingLog.all() }
 60}
 61
 62struct ObservationEditor: View {
 63    @State var observation: Observation
 64    var onSave: () -> Void
 65    @Environment(\.dismiss) private var dismiss
 66    @State private var error: String?
 67    @State private var names: [String: String] = [:]
 68
 69    /// Current inventory names for linked items, else the text stored with the entry.
 70    private var equipmentSummary: String {
 71        let current = observation.equipmentIDs.compactMap { names[$0] }
 72        if !current.isEmpty, current.count == observation.equipmentIDs.count { return current.joined(separator: ", ") }
 73        return observation.equipment.isEmpty ? "Not noted" : observation.equipment
 74    }
 75
 76    var body: some View {
 77        NavigationStack {
 78            Form {
 79                Section {
 80                    TextField("Object", text: $observation.object)
 81                    DatePicker("Time", selection: $observation.date)
 82                    TextField("Place", text: $observation.place)
 83                }
 84                Section("Conditions") {
 85                    NavigationLink { EquipmentPicker(observation: $observation) } label: {
 86                        LabeledContent("Equipment", value: equipmentSummary)
 87                    }
 88                    Picker("Seeing", selection: $observation.seeing) {
 89                        Text("Not noted").tag(Int?.none)
 90                        ForEach(1...5, id: \.self) { Text("\($0) of 5").tag(Int?.some($0)) }
 91                    }
 92                }
 93                Section("Notes") {
 94                    TextEditor(text: $observation.notes).frame(minHeight: 140)
 95                }
 96                if let error { Text(error).foregroundStyle(.red).font(.footnote) }
 97            }
 98            .navigationTitle(observation.object.isEmpty ? "New observation" : observation.object)
 99            .navigationBarTitleDisplayMode(.inline)
100            .toolbar {
101                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
102                ToolbarItem(placement: .confirmationAction) {
103                    Button("Save") {
104                        do {
105                            try observingLog.save(observation)
106                            onSave()
107                            dismiss()
108                        } catch {
109                            self.error = "Couldn't save: \(error.localizedDescription)"
110                        }
111                    }
112                    .disabled(observation.object.trimmingCharacters(in: .whitespaces).isEmpty)
113                }
114            }
115            .onAppear {
116                names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
117            }
118        }
119    }
120}