App/Sources/EquipmentScreen.swift
327 lines · 13858 bytes
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())
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}