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

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

327 lines · 13920 bytes

15 symbols in this file
  1import Charts
  2import ObservingLog
  3import SwiftUI
  4
  5/// The equipment inventory lives in Documents/Equipment.org, one heading per item.
  6let equipmentInventory = EquipmentInventory(file: URL.documentsDirectory.appending(path: "Equipment.org"))
  7
  8extension Equipment {
  9    /// One line of the specs that apply to this kind, e.g. "203 mm · 1200 mm · f/5.9".
 10    var specLine: String {
 11        var parts: [String] = []
 12        for spec in kind.specs {
 13            switch spec {
 14            case .aperture: if let aperture { parts.append("\(Self.format(aperture)) mm aperture") }
 15            case .focalLength: if let focalLength { parts.append("\(Self.format(focalLength)) mm") }
 16            case .magnification: if let magnification { parts.append("\(Self.format(magnification))×") }
 17            case .fieldOfView: if let fieldOfView { parts.append("\(Self.format(fieldOfView))°") }
 18            case .sensor: if !sensor.isEmpty { parts.append(sensor) }
 19            }
 20        }
 21        if kind == .telescope, let focalRatio { parts.append("f/\(Self.format(focalRatio))") }
 22        return parts.joined(separator: " · ")
 23    }
 24
 25    static func format(_ x: Double) -> String { x.formatted(.number.precision(.fractionLength(0...1))) }
 26}
 27
 28extension [Equipment] {
 29    /// Grouped by kind in `Equipment.Kind` order, then by name.
 30    var byKind: [Equipment] {
 31        let order = Dictionary(uniqueKeysWithValues: Equipment.Kind.allCases.enumerated().map { ($1, $0) })
 32        return sorted { order[$0.kind]! != order[$1.kind]! ? order[$0.kind]! < order[$1.kind]! : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
 33    }
 34}
 35
 36struct EquipmentScreen: View {
 37    @State private var items: [Equipment] = []
 38    @State private var editing: Equipment?
 39
 40    var body: some View {
 41        List {
 42            if items.isEmpty {
 43                Text("No equipment yet. Add your telescopes, binoculars, eyepieces and other gear, then pick them when you log an observation.")
 44                    .font(.footnote).foregroundStyle(.secondary)
 45            }
 46            ForEach(Equipment.Kind.allCases) { kind in
 47                let group = items.filter { $0.kind == kind && !$0.retired }
 48                if !group.isEmpty {
 49                    Section(kind.title) { rows(group) }
 50                }
 51            }
 52            let retired = items.filter(\.retired)
 53            if !retired.isEmpty {
 54                Section("Retired") { rows(retired) }
 55            }
 56        }
 57        .navigationTitle("Equipment")
 58        .navigationBarTitleDisplayMode(.inline)
 59        .toolbar {
 60            ToolbarItem(placement: .primaryAction) {
 61                Button { editing = Equipment(kind: .telescope, name: "") } label: { Image(systemName: "plus") }
 62                    .accessibilityLabel("New equipment")
 63            }
 64        }
 65        .sheet(item: $editing) { item in
 66            EquipmentEditor(item: item) { reload() }
 67        }
 68        .onAppear(perform: reload)
 69    }
 70
 71    private func rows(_ group: [Equipment]) -> some View {
 72        ForEach(group) { item in
 73            NavigationLink { EquipmentDetail(item: item, onChange: reload) } label: {
 74                VStack(alignment: .leading, spacing: 3) {
 75                    Text(item.name)
 76                    let line = item.retired ? [item.kind.title, item.specLine].filter { !$0.isEmpty }.joined(separator: " · ") : item.specLine
 77                    if !line.isEmpty { Text(line).font(.footnote).foregroundStyle(.secondary) }
 78                }
 79            }
 80        }
 81        .onDelete { idx in
 82            for i in idx { try? equipmentInventory.delete(id: group[i].id) }
 83            reload()
 84        }
 85    }
 86
 87    private func reload() { items = equipmentInventory.all().byKind }
 88}
 89
 90struct EquipmentDetail: View {
 91    @State var item: Equipment
 92    var onChange: () -> Void
 93    @State private var usage: EquipmentUsage?
 94    @State private var names: [String: String] = [:]
 95    @State private var editing: Equipment?
 96
 97    var body: some View {
 98        List {
 99            Section {
100                LabeledContent("Type", value: item.kind.title)
101                if !item.specLine.isEmpty { LabeledContent("Specs", value: item.specLine) }
102                if item.retired { LabeledContent("Status", value: "Retired") }
103                if !item.notes.isEmpty { Text(item.notes).font(.footnote) }
104            }
105            if let usage { usageSections(usage) }
106        }
107        .navigationTitle(item.name)
108        .navigationBarTitleDisplayMode(.inline)
109        .toolbar {
110            ToolbarItem(placement: .primaryAction) { Button("Edit") { editing = item } }
111        }
112        .sheet(item: $editing) { draft in
113            EquipmentEditor(item: draft) {
114                if let saved = equipmentInventory.all().first(where: { $0.id == draft.id }) { item = saved }
115                onChange()
116            }
117        }
118        .onAppear(perform: reload)
119    }
120
121    @ViewBuilder private func usageSections(_ u: EquipmentUsage) -> some View {
122        Section("Usage") {
123            LabeledContent("Observations", value: "\(u.observations)")
124            if let first = u.first { LabeledContent("First used", value: first.formatted(date: .abbreviated, time: .omitted)) }
125            if let last = u.last { LabeledContent("Last used", value: last.formatted(date: .abbreviated, time: .omitted)) }
126            if u.observations > 0 { LabeledContent("Objects", value: "\(u.distinctObjects)") }
127            if let seeing = u.averageSeeing { LabeledContent("Average seeing", value: "\(seeing.formatted(.number.precision(.fractionLength(1)))) of 5") }
128        }
129        if u.observations > 0 {
130            Section("Most observed") {
131                ForEach(u.topObjects.prefix(5), id: \.key) { LabeledContent($0.key, value: "\($0.count)") }
132            }
133            Section("By month") {
134                Chart(Array(u.byMonth.enumerated()), id: \.offset) { m, count in
135                    BarMark(x: .value("Month", Calendar.current.shortMonthSymbols[m]), y: .value("Observations", count))
136                }
137                .frame(height: 140)
138                .accessibilityLabel("Observations by month")
139                ForEach(EquipmentUsage.Season.allCases, id: \.self) { s in
140                    LabeledContent(s.title, value: "\(u.bySeason[s] ?? 0)")
141                }
142            }
143            if !u.usedWith.isEmpty {
144                Section("Used with") {
145                    ForEach(u.usedWith.prefix(5), id: \.key) { LabeledContent(names[$0.key] ?? "Deleted item", value: "\($0.count)") }
146                }
147            }
148        }
149    }
150
151    private func reload() {
152        names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
153        usage = EquipmentUsage(of: item.id, in: observingLog.all(), resolver: CatalogResolver(programs: observingLog.programs()))
154    }
155}
156
157struct EquipmentEditor: View {
158    @State var item: Equipment
159    var onSave: () -> Void
160    @Environment(\.dismiss) private var dismiss
161    @State private var error: String?
162
163    var body: some View {
164        NavigationStack {
165            Form {
166                Section {
167                    Picker("Type", selection: $item.kind) {
168                        ForEach(Equipment.Kind.allCases) { Text($0.title).tag($0) }
169                    }
170                    TextField("Name", text: $item.name)
171                }
172                if !item.kind.specs.isEmpty {
173                    Section("Specs") {
174                        ForEach(item.kind.specs, id: \.self) { field($0) }
175                        if item.kind == .telescope, let ratio = item.focalRatio {
176                            LabeledContent("Focal ratio", value: "f/\(Equipment.format(ratio))")
177                        }
178                    }
179                }
180                Section("Notes") {
181                    TextEditor(text: $item.notes).frame(minHeight: 100)
182                }
183                Section {
184                    Toggle("Retired", isOn: $item.retired)
185                } footer: {
186                    Text("Retired items leave the picker but stay on the observations that used them.")
187                }
188                if let error { Text(error).foregroundStyle(.red).font(.footnote) }
189            }
190            .navigationTitle(item.name.isEmpty ? "New equipment" : item.name)
191            .navigationBarTitleDisplayMode(.inline)
192            .toolbar {
193                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
194                ToolbarItem(placement: .confirmationAction) {
195                    Button("Save") {
196                        do {
197                            item.name = item.name.trimmingCharacters(in: .whitespaces)
198                            try equipmentInventory.save(item)
199                            onSave()
200                            dismiss()
201                        } catch {
202                            self.error = "Couldn't save: \(error.localizedDescription)"
203                        }
204                    }
205                    .disabled(item.name.trimmingCharacters(in: .whitespaces).isEmpty)
206                }
207            }
208        }
209    }
210
211    @ViewBuilder private func field(_ spec: Equipment.Spec) -> some View {
212        switch spec {
213        case .aperture: number("Aperture", unit: "mm", $item.aperture)
214        case .focalLength: number("Focal length", unit: "mm", $item.focalLength)
215        case .magnification: number(item.kind == .barlow ? "Factor" : "Magnification", unit: "×", $item.magnification)
216        case .fieldOfView: number(item.kind == .eyepiece ? "Apparent field" : "Field of view", unit: "°", $item.fieldOfView)
217        case .sensor: TextField("Sensor", text: $item.sensor)
218        }
219    }
220
221    private func number(_ title: String, unit: String, _ value: Binding<Double?>) -> some View {
222        LabeledContent(title) {
223            HStack(spacing: 4) {
224                NumberField(title: title, value: value)
225                Text(unit).foregroundStyle(.secondary)
226            }
227        }
228    }
229}
230
231/// Decimal entry that updates its binding on every keystroke, so Save never misses an uncommitted value.
232private struct NumberField: View {
233    var title: String
234    @Binding var value: Double?
235    @State private var text = ""
236
237    var body: some View {
238        TextField("", text: $text)
239            .keyboardType(.decimalPad)
240            .multilineTextAlignment(.trailing)
241            .accessibilityLabel(title)
242            .onAppear { text = value.map { $0.formatted(.number.grouping(.never)) } ?? "" }
243            .onChange(of: text) { value = try? Double(text, format: .number) }
244    }
245}
246
247/// Multi-select over the inventory for one observation.
248struct EquipmentPicker: View {
249    @Binding var observation: Observation
250    @State private var items: [Equipment] = []
251    @State private var adding: Equipment?
252
253    static let nakedEye = "Naked eye"
254
255    /// Free text from an entry logged before the inventory existed.
256    private var legacyText: String? {
257        observation.equipmentIDs.isEmpty && !observation.equipment.isEmpty && observation.equipment != Self.nakedEye ? observation.equipment : nil
258    }
259
260    var body: some View {
261        List {
262            if let legacyText {
263                Section {
264                    Text(legacyText)
265                } header: {
266                    Text("Recorded")
267                } footer: {
268                    Text("Picking items below replaces this.")
269                }
270            }
271            Section {
272                row(Self.nakedEye, selected: observation.equipmentIDs.isEmpty && observation.equipment == Self.nakedEye) {
273                    observation.equipmentIDs = []
274                    observation.equipment = observation.equipment == Self.nakedEye ? "" : Self.nakedEye
275                }
276            }
277            ForEach(Equipment.Kind.allCases) { kind in
278                let group = items.filter { $0.kind == kind && (!$0.retired || observation.equipmentIDs.contains($0.id)) }
279                if !group.isEmpty {
280                    Section(kind.title) {
281                        ForEach(group) { item in
282                            row(item.name, detail: item.specLine, selected: observation.equipmentIDs.contains(item.id)) { toggle(item) }
283                        }
284                    }
285                }
286            }
287            Section {
288                Button { adding = Equipment(kind: .telescope, name: "") } label: { Label("Add equipment", systemImage: "plus") }
289            }
290        }
291        .navigationTitle("Equipment")
292        .navigationBarTitleDisplayMode(.inline)
293        .sheet(item: $adding) { item in
294            EquipmentEditor(item: item) {
295                reload()
296                if let saved = items.first(where: { $0.id == item.id }) { toggle(saved) }
297            }
298        }
299        .onAppear(perform: reload)
300    }
301
302    private func row(_ title: String, detail: String = "", selected: Bool, action: @escaping () -> Void) -> some View {
303        Button(action: action) {
304            HStack {
305                VStack(alignment: .leading, spacing: 3) {
306                    Text(title)
307                    if !detail.isEmpty { Text(detail).font(.footnote).foregroundStyle(.secondary) }
308                }
309                Spacer()
310                if selected { Image(systemName: "checkmark").foregroundStyle(.tint) }
311            }
312            .contentShape(.rect)
313        }
314        .buttonStyle(.plain)
315        .accessibilityAddTraits(selected ? .isSelected : [])
316    }
317
318    private func toggle(_ item: Equipment) {
319        var ids = Set(observation.equipmentIDs)
320        if ids.contains(item.id) { ids.remove(item.id) } else { ids.insert(item.id) }
321        let chosen = items.filter { ids.contains($0.id) }
322        observation.equipmentIDs = chosen.map(\.id)
323        observation.equipment = chosen.map(\.name).joined(separator: ", ")
324    }
325
326    private func reload() { items = equipmentInventory.all().byKind }
327}