USB-Meter / USB Meter / Views / ChargedDevices / Sheets / Editors / ChargedDeviceEditorSheetView.swift
Newer Older
500 lines | 19.673kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargedDeviceEditorSheetView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
struct ChargedDeviceEditorSheetView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @Environment(\.dismiss) private var dismiss
13

            
14
    let meterMACAddress: String?
15
    let chargedDevice: ChargedDeviceSummary?
16

            
17
    @State private var name: String
Bogdan Timofte authored a month ago
18
    @State private var notes: String
Bogdan Timofte authored a month ago
19
    @State private var deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
20
    @State private var selectedTemplateID: String?
21
    @State private var lastAppliedTemplateID: String?
Bogdan Timofte authored a month ago
22
    @State private var chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
23
    @State private var supportsWiredCharging: Bool
24
    @State private var supportsWirelessCharging: Bool
25
    @State private var wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
26
    @State private var completionCurrentTexts: [ChargeSessionKind: String]
Bogdan Timofte authored a month ago
27

            
28
    let standalone: Bool
Bogdan Timofte authored a month ago
29

            
30
    init(
31
        meterMACAddress: String?,
Bogdan Timofte authored a month ago
32
        chargedDevice: ChargedDeviceSummary? = nil,
33
        standalone: Bool = true
Bogdan Timofte authored a month ago
34
    ) {
35
        self.meterMACAddress = meterMACAddress
36
        self.chargedDevice = chargedDevice
Bogdan Timofte authored a month ago
37
        self.standalone = standalone
Bogdan Timofte authored a month ago
38

            
Bogdan Timofte authored a month ago
39
        _name = State(initialValue: chargedDevice?.name ?? "")
40
        _notes = State(initialValue: chargedDevice?.notes ?? "")
Bogdan Timofte authored a month ago
41

            
Bogdan Timofte authored a month ago
42
        let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone
Bogdan Timofte authored a month ago
43
        let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
Bogdan Timofte authored a month ago
44
            chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability
Bogdan Timofte authored a month ago
45
        )
Bogdan Timofte authored a month ago
46
        let defaultChargingSupport = initialDeviceClass.defaultChargingSupport
Bogdan Timofte authored a month ago
47
        let initialChargingSupport = initialDeviceClass.normalizedChargingSupport(
Bogdan Timofte authored a month ago
48
            supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultChargingSupport.wired,
49
            supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultChargingSupport.wireless
Bogdan Timofte authored a month ago
50
        )
51
        let initialTemplateID = chargedDevice?.deviceTemplateID
Bogdan Timofte authored a month ago
52
        _deviceClass = State(initialValue: initialDeviceClass)
Bogdan Timofte authored a month ago
53
        _selectedTemplateID = State(initialValue: initialTemplateID)
54
        _lastAppliedTemplateID = State(initialValue: initialTemplateID)
55
        _chargingStateAvailability = State(initialValue: initialChargingStateAvailability)
56
        _supportsWiredCharging = State(initialValue: initialChargingSupport.wired)
57
        _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless)
Bogdan Timofte authored a month ago
58
        _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
Bogdan Timofte authored a month ago
59
        _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
Bogdan Timofte authored a month ago
60
    }
61

            
62
    var body: some View {
Bogdan Timofte authored a month ago
63
        ChargedDeviceEditorScaffoldView(
64
            title: editorTitle,
65
            saveButtonTitle: saveButtonTitle,
66
            canSave: canSave,
67
            standalone: standalone,
68
            save: save
69
        ) {
Bogdan Timofte authored a month ago
70
            identitySection
71
            templateSection
72
            deviceChargeBehaviourSection
73
            deviceChargingSupportSection
74
            deviceCompletionSection
75
            notesSection
76
        }
Bogdan Timofte authored a month ago
77
        .onChange(of: deviceClass) { newValue in
Bogdan Timofte authored a month ago
78
            applyDeviceClassRules(for: newValue)
79
        }
80
        .onChange(of: selectedTemplateID) { newValue in
81
            applyTemplateSelection(
82
                previousTemplateID: lastAppliedTemplateID,
83
                newTemplateID: newValue
84
            )
85
            lastAppliedTemplateID = newValue
Bogdan Timofte authored a month ago
86
        }
87
        .onAppear {
Bogdan Timofte authored a month ago
88
            applyDeviceClassRules(for: deviceClass)
Bogdan Timofte authored a month ago
89
        }
90
    }
91

            
Bogdan Timofte authored a month ago
92
    private var identitySection: some View {
93
        Section(header: Text("Identity")) {
Bogdan Timofte authored a month ago
94
            TextField("Name", text: $name)
Bogdan Timofte authored a month ago
95

            
Bogdan Timofte authored a month ago
96
            Picker("Class", selection: $deviceClass) {
97
                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
98
                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
99
                        .tag(deviceClass)
Bogdan Timofte authored a month ago
100
                }
101
            }
102

            
103
            if let chargedDevice {
104
                Text(chargedDevice.qrIdentifier)
105
                    .font(.caption.monospaced())
106
                    .foregroundColor(.secondary)
107
                    .textSelection(.enabled)
108
            }
109
        }
110
    }
111

            
Bogdan Timofte authored a month ago
112
    private var templateSection: some View {
113
        Section(
114
            header: ContextInfoHeader(
115
                title: "Template",
116
                message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers."
117
            )
118
        ) {
119
            Picker("Template", selection: $selectedTemplateID) {
120
                Text("Custom")
121
                    .tag(String?.none)
122

            
123
                ForEach(groupedTemplates, id: \.group) { group in
124
                    Section(group.group) {
125
                        ForEach(group.templates) { template in
126
                            Text(template.name)
127
                                .tag(template.id as String?)
128
                        }
129
                    }
130
                }
131
            }
132

            
133
            if let selectedTemplate {
134
                ChargedDeviceTemplateLabelView(
135
                    template: selectedTemplate,
136
                    iconPointSize: 18
137
                )
138
                .font(.subheadline.weight(.semibold))
139

            
140
                Text(selectedTemplate.capabilitySummary)
141
                    .font(.caption)
142
                    .foregroundColor(.secondary)
143
            } else {
144
                Text("Choose a template when you want a predefined icon and a starting charging setup.")
145
                    .font(.caption)
146
                    .foregroundColor(.secondary)
147
            }
148
        }
149
    }
150

            
Bogdan Timofte authored a month ago
151
    private var deviceChargeBehaviourSection: some View {
152
        Section(
153
            header: ContextInfoHeader(
154
                title: "Charge Behaviour",
155
                message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
156
            )
157
        ) {
Bogdan Timofte authored a month ago
158
            if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
159
                VStack(alignment: .leading, spacing: 6) {
160
                    Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill")
161
                        .font(.subheadline.weight(.semibold))
162
                    Text(enforcedChargingStateAvailability.description)
163
                        .font(.caption)
164
                        .foregroundColor(.secondary)
165
                }
166
            } else {
167
                Picker("Session Modes", selection: $chargingStateAvailability) {
168
                    ForEach(ChargingStateAvailability.allCases) { availability in
169
                        Text(availability.title)
170
                            .tag(availability)
171
                    }
Bogdan Timofte authored a month ago
172
                }
173
            }
174
        }
175
    }
176

            
177
    private var deviceChargingSupportSection: some View {
178
        Section(
179
            header: ContextInfoHeader(
180
                title: "Charging Support",
181
                message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
182
            )
183
        ) {
Bogdan Timofte authored a month ago
184
            if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
185
                VStack(alignment: .leading, spacing: 6) {
186
                    Label(
187
                        Self.chargingSupportDescription(
188
                            supportsWiredCharging: enforcedChargingSupport.wired,
189
                            supportsWirelessCharging: enforcedChargingSupport.wireless
190
                        ),
191
                        systemImage: "lock.fill"
192
                    )
193
                    .font(.subheadline.weight(.semibold))
194

            
195
                    Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.")
196
                        .font(.caption)
197
                        .foregroundColor(.secondary)
198
                }
199
            } else {
200
                Toggle("Supports wired charging", isOn: $supportsWiredCharging)
201
                Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
202
            }
Bogdan Timofte authored a month ago
203

            
Bogdan Timofte authored a month ago
204
            if showsWirelessProfilePicker {
Bogdan Timofte authored a month ago
205
                Picker("Wireless profile", selection: $wirelessChargingProfile) {
206
                    ForEach(WirelessChargingProfile.allCases) { profile in
207
                        Text(profile.title)
208
                            .tag(profile)
209
                    }
210
                }
211

            
212
            }
213

            
214
            if supportedChargingModes.isEmpty {
215
                Text("Enable at least one charging method.")
216
                    .font(.footnote)
217
                    .foregroundColor(.secondary)
218
            }
219
        }
220
    }
221

            
222
    private var deviceCompletionSection: some View {
223
        Section(
224
            header: ContextInfoHeader(
225
                title: "Charge Completion",
226
                message: "Completion currents can be set per session type. Leave a value empty to keep learning that threshold automatically from sessions of the same type."
227
            )
228
        ) {
229
            if applicableSessionKinds.isEmpty {
230
                Text("Enable at least one charging method to configure stop currents.")
231
                    .font(.footnote)
232
                    .foregroundColor(.secondary)
233
            } else {
234
                ForEach(applicableSessionKinds) { sessionKind in
235
                    VStack(alignment: .leading, spacing: 6) {
236
                        TextField(
Bogdan Timofte authored a month ago
237
                            completionCurrentFieldLabel(for: sessionKind),
Bogdan Timofte authored a month ago
238
                            text: completionCurrentTextBinding(for: sessionKind)
239
                        )
240
                        .keyboardType(.decimalPad)
241
                    }
242
                    .padding(.vertical, 2)
243
                }
244
            }
245
        }
246
    }
247

            
248
    private var notesSection: some View {
249
        Section(header: Text("Notes")) {
250
            TextField("Optional notes", text: $notes)
251
        }
252
    }
253

            
Bogdan Timofte authored a month ago
254
    private var editorTitle: String {
Bogdan Timofte authored a month ago
255
        chargedDevice == nil ? "New Device" : "Edit Device"
Bogdan Timofte authored a month ago
256
    }
257

            
258
    private var saveButtonTitle: String {
259
        chargedDevice == nil ? "Save" : "Update"
260
    }
261

            
Bogdan Timofte authored a month ago
262
    private var canSave: Bool {
Bogdan Timofte authored a month ago
263
        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Bogdan Timofte authored a month ago
264
            && (supportsWiredCharging || supportsWirelessCharging)
265
            && !hasInvalidCompletionCurrentEntry
266
    }
267

            
Bogdan Timofte authored a month ago
268
    private var availableTemplates: [ChargedDeviceTemplateDefinition] {
Bogdan Timofte authored a month ago
269
        ChargedDeviceTemplateCatalog.shared.templates(for: .device)
Bogdan Timofte authored a month ago
270
    }
271

            
272
    private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
273
        Dictionary(grouping: availableTemplates, by: \.group)
274
            .keys
275
            .sorted()
276
            .map { group in
277
                (
278
                    group: group,
279
                    templates: availableTemplates.filter { $0.group == group }
280
                )
281
            }
282
    }
283

            
284
    private var selectedTemplate: ChargedDeviceTemplateDefinition? {
285
        ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID)
286
    }
287

            
Bogdan Timofte authored a month ago
288
    private var supportedChargingModes: [ChargingTransportMode] {
289
        var modes: [ChargingTransportMode] = []
290
        if supportsWiredCharging {
291
            modes.append(.wired)
292
        }
293
        if supportsWirelessCharging {
294
            modes.append(.wireless)
295
        }
296
        return modes
297
    }
298

            
Bogdan Timofte authored a month ago
299
    private var applicableSessionKinds: [ChargeSessionKind] {
300
        supportedChargingModes.flatMap { chargingTransportMode in
301
            chargingStateAvailability.supportedModes.map { chargingStateMode in
302
                ChargeSessionKind(
303
                    chargingTransportMode: chargingTransportMode,
304
                    chargingStateMode: chargingStateMode
305
                )
306
            }
307
        }
308
    }
309

            
Bogdan Timofte authored a month ago
310
    private var showsWirelessProfilePicker: Bool {
311
        supportsWirelessCharging
312
            && deviceClass != .watch
313
            && supportedChargingModes.count > 1
314
    }
315

            
Bogdan Timofte authored a month ago
316
    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
317
        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
318
            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
319
                return
320
            }
321
            result[sessionKind] = value
322
        }
323
    }
324

            
325
    private var hasInvalidCompletionCurrentEntry: Bool {
326
        applicableSessionKinds.contains { sessionKind in
327
            let text = completionCurrentTexts[sessionKind] ?? ""
328
            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
329
            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
330
        }
331
    }
332

            
333
    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
334
        Binding(
335
            get: { completionCurrentTexts[sessionKind] ?? "" },
336
            set: { completionCurrentTexts[sessionKind] = $0 }
337
        )
338
    }
339

            
Bogdan Timofte authored a month ago
340
    private func save() {
Bogdan Timofte authored a month ago
341
        let configuredCompletionCurrents = parsedCompletionCurrents
Bogdan Timofte authored a month ago
342
        let didSave: Bool
343

            
Bogdan Timofte authored a month ago
344
        if let chargedDevice {
345
            didSave = appData.updateDevice(
346
                id: chargedDevice.id,
347
                name: name,
348
                deviceClass: deviceClass,
349
                templateID: selectedTemplateID,
350
                chargingStateAvailability: chargingStateAvailability,
351
                supportsWiredCharging: supportsWiredCharging,
352
                supportsWirelessCharging: supportsWirelessCharging,
353
                wirelessChargingProfile: wirelessChargingProfile,
354
                configuredCompletionCurrents: configuredCompletionCurrents,
355
                notes: notes
356
            )
Bogdan Timofte authored a month ago
357
        } else {
Bogdan Timofte authored a month ago
358
            didSave = appData.createDevice(
359
                name: name,
360
                deviceClass: deviceClass,
361
                templateID: selectedTemplateID,
362
                chargingStateAvailability: chargingStateAvailability,
363
                supportsWiredCharging: supportsWiredCharging,
364
                supportsWirelessCharging: supportsWirelessCharging,
365
                wirelessChargingProfile: wirelessChargingProfile,
366
                configuredCompletionCurrents: configuredCompletionCurrents,
367
                notes: notes,
368
                meterMACAddress: meterMACAddress
369
            )
Bogdan Timofte authored a month ago
370
        }
371

            
372
        if didSave {
373
            dismiss()
374
        }
375
    }
376

            
Bogdan Timofte authored a month ago
377
    private func applyTemplateSelection(
378
        previousTemplateID: String?,
379
        newTemplateID: String?
380
    ) {
381
        guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else {
Bogdan Timofte authored a month ago
382
            return
383
        }
384

            
Bogdan Timofte authored a month ago
385
        let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID)
386
        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
387
        if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
388
            name = newTemplate.name
389
        }
Bogdan Timofte authored a month ago
390

            
Bogdan Timofte authored a month ago
391
        deviceClass = newTemplate.deviceClass
392
        chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability(
393
            newTemplate.chargingStateAvailability
394
        )
395

            
396
        let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport(
397
            supportsWiredCharging: newTemplate.supportsWiredCharging,
398
            supportsWirelessCharging: newTemplate.supportsWirelessCharging
399
        )
400
        supportsWiredCharging = normalizedChargingSupport.wired
401
        supportsWirelessCharging = normalizedChargingSupport.wireless
402
        wirelessChargingProfile = newTemplate.wirelessChargingProfile
403
    }
404

            
405
    private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
406
        if let selectedTemplate {
407
            chargingStateAvailability = deviceClass.normalizedChargingStateAvailability(
408
                selectedTemplate.chargingStateAvailability
409
            )
410
            let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
411
                supportsWiredCharging: selectedTemplate.supportsWiredCharging,
412
                supportsWirelessCharging: selectedTemplate.supportsWirelessCharging
413
            )
414
            supportsWiredCharging = normalizedChargingSupport.wired
415
            supportsWirelessCharging = normalizedChargingSupport.wireless
416
            wirelessChargingProfile = selectedTemplate.wirelessChargingProfile
417
            return
418
        }
419

            
420
        if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
421
            chargingStateAvailability = enforcedChargingStateAvailability
422
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
423
            chargingStateAvailability = deviceClass.defaultChargingStateAvailability
Bogdan Timofte authored a month ago
424
        }
425

            
426
        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
427
            supportsWiredCharging = enforcedChargingSupport.wired
428
            supportsWirelessCharging = enforcedChargingSupport.wireless
429
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
430
            let defaultChargingSupport = deviceClass.defaultChargingSupport
431
            supportsWiredCharging = defaultChargingSupport.wired
432
            supportsWirelessCharging = defaultChargingSupport.wireless
Bogdan Timofte authored a month ago
433
        }
434
    }
435

            
436
    private func parsedOptionalCurrent(_ text: String) -> Double? {
437
        let normalized = text
438
            .trimmingCharacters(in: .whitespacesAndNewlines)
439
            .replacingOccurrences(of: ",", with: ".")
440
        guard !normalized.isEmpty else {
441
            return nil
442
        }
Bogdan Timofte authored a month ago
443
        guard let value = Double(normalized), value > 0 else {
444
            return nil
445
        }
446
        return value
447
    }
448

            
Bogdan Timofte authored a month ago
449
    private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
450
        let showsTransport = supportedChargingModes.count > 1
451
        let showsState = chargingStateAvailability.supportedModes.count > 1
452

            
453
        switch (showsTransport, showsState) {
454
        case (true, true):
455
            return "\(sessionKind.shortTitle) completion current (A)"
456
        case (true, false):
457
            return "\(sessionKind.chargingTransportMode.title) completion current (A)"
458
        case (false, true):
459
            return "\(sessionKind.chargingStateMode.title) completion current (A)"
460
        case (false, false):
461
            return "Stop current (A)"
462
        }
463
    }
464

            
Bogdan Timofte authored a month ago
465
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
466
        guard let chargedDevice else {
467
            return [:]
468
        }
469

            
470
        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
471
            result[sessionKind] = optionalCurrentText(
472
                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
473
            )
474
        }
Bogdan Timofte authored a month ago
475
    }
476

            
477
    private static func optionalCurrentText(_ value: Double?) -> String {
478
        guard let value else {
479
            return ""
480
        }
481
        return value.format(decimalDigits: 2)
482
    }
Bogdan Timofte authored a month ago
483

            
Bogdan Timofte authored a month ago
484
    private static func chargingSupportDescription(
485
        supportsWiredCharging: Bool,
486
        supportsWirelessCharging: Bool
487
    ) -> String {
488
        switch (supportsWiredCharging, supportsWirelessCharging) {
489
        case (true, true):
490
            return "Supports wired and wireless charging"
491
        case (true, false):
492
            return "Supports wired charging only"
493
        case (false, true):
494
            return "Supports wireless charging only"
495
        case (false, false):
496
            return "No charging method configured"
497
        }
498
    }
499

            
Bogdan Timofte authored a month ago
500
}