USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceEditorSheetView.swift
Newer Older
543 lines | 20.878kb
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(
44
            chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
45
        )
46
        let initialChargingSupport = initialDeviceClass.normalizedChargingSupport(
47
            supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? true,
48
            supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? true
49
        )
50
        let initialTemplateID = chargedDevice?.deviceTemplateID
Bogdan Timofte authored a month ago
51
        _deviceClass = State(initialValue: initialDeviceClass)
Bogdan Timofte authored a month ago
52
        _selectedTemplateID = State(initialValue: initialTemplateID)
53
        _lastAppliedTemplateID = State(initialValue: initialTemplateID)
54
        _chargingStateAvailability = State(initialValue: initialChargingStateAvailability)
55
        _supportsWiredCharging = State(initialValue: initialChargingSupport.wired)
56
        _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless)
Bogdan Timofte authored a month ago
57
        _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
Bogdan Timofte authored a month ago
58
        _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
Bogdan Timofte authored a month ago
59
    }
60

            
61
    var body: some View {
Bogdan Timofte authored a month ago
62
        if standalone {
63
            NavigationView { formContent }
64
                .navigationViewStyle(StackNavigationViewStyle())
65
        } else {
66
            formContent
67
        }
68
    }
Bogdan Timofte authored a month ago
69

            
Bogdan Timofte authored a month ago
70
    private var formContent: some View {
71
        Form {
72
            identitySection
73
            templateSection
74
            deviceChargeBehaviourSection
75
            deviceChargingSupportSection
76
            deviceCompletionSection
77
            notesSection
78
        }
79
        .navigationTitle(editorTitle)
80
        .navigationBarTitleDisplayMode(.inline)
81
        .toolbar {
82
            ToolbarItem(placement: .cancellationAction) {
83
                Button("Cancel") {
84
                    dismiss()
Bogdan Timofte authored a month ago
85
                }
Bogdan Timofte authored a month ago
86
            }
87
            ToolbarItem(placement: .confirmationAction) {
88
                Button(saveButtonTitle) {
89
                    save()
Bogdan Timofte authored a month ago
90
                }
Bogdan Timofte authored a month ago
91
                .disabled(!canSave)
Bogdan Timofte authored a month ago
92
            }
93
        }
94
        .onChange(of: deviceClass) { newValue in
Bogdan Timofte authored a month ago
95
            applyDeviceClassRules(for: newValue)
96
        }
97
        .onChange(of: selectedTemplateID) { newValue in
98
            applyTemplateSelection(
99
                previousTemplateID: lastAppliedTemplateID,
100
                newTemplateID: newValue
101
            )
102
            lastAppliedTemplateID = newValue
Bogdan Timofte authored a month ago
103
        }
104
        .onAppear {
Bogdan Timofte authored a month ago
105
            applyDeviceClassRules(for: deviceClass)
Bogdan Timofte authored a month ago
106
        }
107
    }
108

            
Bogdan Timofte authored a month ago
109
    private var identitySection: some View {
110
        Section(header: Text("Identity")) {
Bogdan Timofte authored a month ago
111
            TextField("Name", text: $name)
Bogdan Timofte authored a month ago
112

            
Bogdan Timofte authored a month ago
113
            Picker("Class", selection: $deviceClass) {
114
                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
115
                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
116
                        .tag(deviceClass)
Bogdan Timofte authored a month ago
117
                }
118
            }
119

            
120
            if let chargedDevice {
121
                Text(chargedDevice.qrIdentifier)
122
                    .font(.caption.monospaced())
123
                    .foregroundColor(.secondary)
124
                    .textSelection(.enabled)
125
            }
126
        }
127
    }
128

            
Bogdan Timofte authored a month ago
129
    private var templateSection: some View {
130
        Section(
131
            header: ContextInfoHeader(
132
                title: "Template",
133
                message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers."
134
            )
135
        ) {
136
            Picker("Template", selection: $selectedTemplateID) {
137
                Text("Custom")
138
                    .tag(String?.none)
139

            
140
                ForEach(groupedTemplates, id: \.group) { group in
141
                    Section(group.group) {
142
                        ForEach(group.templates) { template in
143
                            Text(template.name)
144
                                .tag(template.id as String?)
145
                        }
146
                    }
147
                }
148
            }
149

            
150
            if let selectedTemplate {
151
                ChargedDeviceTemplateLabelView(
152
                    template: selectedTemplate,
153
                    iconPointSize: 18
154
                )
155
                .font(.subheadline.weight(.semibold))
156

            
157
                Text(selectedTemplate.capabilitySummary)
158
                    .font(.caption)
159
                    .foregroundColor(.secondary)
160
            } else {
161
                Text("Choose a template when you want a predefined icon and a starting charging setup.")
162
                    .font(.caption)
163
                    .foregroundColor(.secondary)
164
            }
165
        }
166
    }
167

            
Bogdan Timofte authored a month ago
168
    private var deviceChargeBehaviourSection: some View {
169
        Section(
170
            header: ContextInfoHeader(
171
                title: "Charge Behaviour",
172
                message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
173
            )
174
        ) {
Bogdan Timofte authored a month ago
175
            if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
176
                VStack(alignment: .leading, spacing: 6) {
177
                    Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill")
178
                        .font(.subheadline.weight(.semibold))
179
                    Text(enforcedChargingStateAvailability.description)
180
                        .font(.caption)
181
                        .foregroundColor(.secondary)
182
                }
183
            } else {
184
                Picker("Session Modes", selection: $chargingStateAvailability) {
185
                    ForEach(ChargingStateAvailability.allCases) { availability in
186
                        Text(availability.title)
187
                            .tag(availability)
188
                    }
Bogdan Timofte authored a month ago
189
                }
190
            }
191
        }
192
    }
193

            
194
    private var deviceChargingSupportSection: some View {
195
        Section(
196
            header: ContextInfoHeader(
197
                title: "Charging Support",
198
                message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
199
            )
200
        ) {
Bogdan Timofte authored a month ago
201
            if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
202
                VStack(alignment: .leading, spacing: 6) {
203
                    Label(
204
                        Self.chargingSupportDescription(
205
                            supportsWiredCharging: enforcedChargingSupport.wired,
206
                            supportsWirelessCharging: enforcedChargingSupport.wireless
207
                        ),
208
                        systemImage: "lock.fill"
209
                    )
210
                    .font(.subheadline.weight(.semibold))
211

            
212
                    Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.")
213
                        .font(.caption)
214
                        .foregroundColor(.secondary)
215
                }
216
            } else {
217
                Toggle("Supports wired charging", isOn: $supportsWiredCharging)
218
                Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
219
            }
Bogdan Timofte authored a month ago
220

            
Bogdan Timofte authored a month ago
221
            if showsWirelessProfilePicker {
Bogdan Timofte authored a month ago
222
                Picker("Wireless profile", selection: $wirelessChargingProfile) {
223
                    ForEach(WirelessChargingProfile.allCases) { profile in
224
                        Text(profile.title)
225
                            .tag(profile)
226
                    }
227
                }
228

            
229
            }
230

            
231
            if supportedChargingModes.isEmpty {
232
                Text("Enable at least one charging method.")
233
                    .font(.footnote)
234
                    .foregroundColor(.secondary)
235
            }
236
        }
237
    }
238

            
239
    private var deviceCompletionSection: some View {
240
        Section(
241
            header: ContextInfoHeader(
242
                title: "Charge Completion",
243
                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."
244
            )
245
        ) {
246
            if applicableSessionKinds.isEmpty {
247
                Text("Enable at least one charging method to configure stop currents.")
248
                    .font(.footnote)
249
                    .foregroundColor(.secondary)
250
            } else {
251
                ForEach(applicableSessionKinds) { sessionKind in
252
                    VStack(alignment: .leading, spacing: 6) {
253
                        TextField(
Bogdan Timofte authored a month ago
254
                            completionCurrentFieldLabel(for: sessionKind),
Bogdan Timofte authored a month ago
255
                            text: completionCurrentTextBinding(for: sessionKind)
256
                        )
257
                        .keyboardType(.decimalPad)
258
                    }
259
                    .padding(.vertical, 2)
260
                }
261
            }
262
        }
263
    }
264

            
265
    private var notesSection: some View {
266
        Section(header: Text("Notes")) {
267
            TextField("Optional notes", text: $notes)
268
        }
269
    }
270

            
Bogdan Timofte authored a month ago
271
    private var editorTitle: String {
Bogdan Timofte authored a month ago
272
        chargedDevice == nil ? "New Device" : "Edit Device"
Bogdan Timofte authored a month ago
273
    }
274

            
275
    private var saveButtonTitle: String {
276
        chargedDevice == nil ? "Save" : "Update"
277
    }
278

            
Bogdan Timofte authored a month ago
279
    private var canSave: Bool {
Bogdan Timofte authored a month ago
280
        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Bogdan Timofte authored a month ago
281
            && (supportsWiredCharging || supportsWirelessCharging)
282
            && !hasInvalidCompletionCurrentEntry
283
    }
284

            
Bogdan Timofte authored a month ago
285
    private var availableTemplates: [ChargedDeviceTemplateDefinition] {
Bogdan Timofte authored a month ago
286
        ChargedDeviceTemplateCatalog.shared.templates(for: .device)
Bogdan Timofte authored a month ago
287
    }
288

            
289
    private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
290
        Dictionary(grouping: availableTemplates, by: \.group)
291
            .keys
292
            .sorted()
293
            .map { group in
294
                (
295
                    group: group,
296
                    templates: availableTemplates.filter { $0.group == group }
297
                )
298
            }
299
    }
300

            
301
    private var selectedTemplate: ChargedDeviceTemplateDefinition? {
302
        ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID)
303
    }
304

            
Bogdan Timofte authored a month ago
305
    private var supportedChargingModes: [ChargingTransportMode] {
306
        var modes: [ChargingTransportMode] = []
307
        if supportsWiredCharging {
308
            modes.append(.wired)
309
        }
310
        if supportsWirelessCharging {
311
            modes.append(.wireless)
312
        }
313
        return modes
314
    }
315

            
Bogdan Timofte authored a month ago
316
    private var applicableSessionKinds: [ChargeSessionKind] {
317
        supportedChargingModes.flatMap { chargingTransportMode in
318
            chargingStateAvailability.supportedModes.map { chargingStateMode in
319
                ChargeSessionKind(
320
                    chargingTransportMode: chargingTransportMode,
321
                    chargingStateMode: chargingStateMode
322
                )
323
            }
324
        }
325
    }
326

            
Bogdan Timofte authored a month ago
327
    private var showsWirelessProfilePicker: Bool {
328
        supportsWirelessCharging
329
            && deviceClass != .watch
330
            && supportedChargingModes.count > 1
331
    }
332

            
Bogdan Timofte authored a month ago
333
    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
334
        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
335
            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
336
                return
337
            }
338
            result[sessionKind] = value
339
        }
340
    }
341

            
342
    private var hasInvalidCompletionCurrentEntry: Bool {
343
        applicableSessionKinds.contains { sessionKind in
344
            let text = completionCurrentTexts[sessionKind] ?? ""
345
            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
346
            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
347
        }
348
    }
349

            
350
    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
351
        Binding(
352
            get: { completionCurrentTexts[sessionKind] ?? "" },
353
            set: { completionCurrentTexts[sessionKind] = $0 }
354
        )
355
    }
356

            
Bogdan Timofte authored a month ago
357
    private func save() {
Bogdan Timofte authored a month ago
358
        let configuredCompletionCurrents = parsedCompletionCurrents
Bogdan Timofte authored a month ago
359
        let didSave: Bool
360

            
Bogdan Timofte authored a month ago
361
        if let chargedDevice {
362
            didSave = appData.updateDevice(
363
                id: chargedDevice.id,
364
                name: name,
365
                deviceClass: deviceClass,
366
                templateID: selectedTemplateID,
367
                chargingStateAvailability: chargingStateAvailability,
368
                supportsWiredCharging: supportsWiredCharging,
369
                supportsWirelessCharging: supportsWirelessCharging,
370
                wirelessChargingProfile: wirelessChargingProfile,
371
                configuredCompletionCurrents: configuredCompletionCurrents,
372
                notes: notes
373
            )
Bogdan Timofte authored a month ago
374
        } else {
Bogdan Timofte authored a month ago
375
            didSave = appData.createDevice(
376
                name: name,
377
                deviceClass: deviceClass,
378
                templateID: selectedTemplateID,
379
                chargingStateAvailability: chargingStateAvailability,
380
                supportsWiredCharging: supportsWiredCharging,
381
                supportsWirelessCharging: supportsWirelessCharging,
382
                wirelessChargingProfile: wirelessChargingProfile,
383
                configuredCompletionCurrents: configuredCompletionCurrents,
384
                notes: notes,
385
                meterMACAddress: meterMACAddress
386
            )
Bogdan Timofte authored a month ago
387
        }
388

            
389
        if didSave {
390
            dismiss()
391
        }
392
    }
393

            
Bogdan Timofte authored a month ago
394
    private func applyTemplateSelection(
395
        previousTemplateID: String?,
396
        newTemplateID: String?
397
    ) {
398
        guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else {
Bogdan Timofte authored a month ago
399
            return
400
        }
401

            
Bogdan Timofte authored a month ago
402
        let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID)
403
        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
404
        if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
405
            name = newTemplate.name
406
        }
Bogdan Timofte authored a month ago
407

            
Bogdan Timofte authored a month ago
408
        deviceClass = newTemplate.deviceClass
409
        chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability(
410
            newTemplate.chargingStateAvailability
411
        )
412

            
413
        let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport(
414
            supportsWiredCharging: newTemplate.supportsWiredCharging,
415
            supportsWirelessCharging: newTemplate.supportsWirelessCharging
416
        )
417
        supportsWiredCharging = normalizedChargingSupport.wired
418
        supportsWirelessCharging = normalizedChargingSupport.wireless
419
        wirelessChargingProfile = newTemplate.wirelessChargingProfile
420
    }
421

            
422
    private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
423
        if let selectedTemplate {
424
            chargingStateAvailability = deviceClass.normalizedChargingStateAvailability(
425
                selectedTemplate.chargingStateAvailability
426
            )
427
            let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
428
                supportsWiredCharging: selectedTemplate.supportsWiredCharging,
429
                supportsWirelessCharging: selectedTemplate.supportsWirelessCharging
430
            )
431
            supportsWiredCharging = normalizedChargingSupport.wired
432
            supportsWirelessCharging = normalizedChargingSupport.wireless
433
            wirelessChargingProfile = selectedTemplate.wirelessChargingProfile
434
            return
435
        }
436

            
437
        if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
438
            chargingStateAvailability = enforcedChargingStateAvailability
439
        } else if chargedDevice == nil {
440
            chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
441
        }
442

            
443
        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
444
            supportsWiredCharging = enforcedChargingSupport.wired
445
            supportsWirelessCharging = enforcedChargingSupport.wireless
446
        } else if chargedDevice == nil {
447
            switch deviceClass {
448
            case .iphone:
449
                supportsWiredCharging = true
450
                supportsWirelessCharging = true
451
            case .watch:
452
                supportsWiredCharging = false
453
                supportsWirelessCharging = true
454
            case .powerbank:
455
                supportsWiredCharging = true
456
                supportsWirelessCharging = false
457
            case .charger:
458
                supportsWiredCharging = false
459
                supportsWirelessCharging = true
460
            case .other:
461
                supportsWiredCharging = true
462
                supportsWirelessCharging = false
463
            }
Bogdan Timofte authored a month ago
464
        }
465
    }
466

            
467
    private func parsedOptionalCurrent(_ text: String) -> Double? {
468
        let normalized = text
469
            .trimmingCharacters(in: .whitespacesAndNewlines)
470
            .replacingOccurrences(of: ",", with: ".")
471
        guard !normalized.isEmpty else {
472
            return nil
473
        }
Bogdan Timofte authored a month ago
474
        guard let value = Double(normalized), value > 0 else {
475
            return nil
476
        }
477
        return value
478
    }
479

            
Bogdan Timofte authored a month ago
480
    private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
481
        let showsTransport = supportedChargingModes.count > 1
482
        let showsState = chargingStateAvailability.supportedModes.count > 1
483

            
484
        switch (showsTransport, showsState) {
485
        case (true, true):
486
            return "\(sessionKind.shortTitle) completion current (A)"
487
        case (true, false):
488
            return "\(sessionKind.chargingTransportMode.title) completion current (A)"
489
        case (false, true):
490
            return "\(sessionKind.chargingStateMode.title) completion current (A)"
491
        case (false, false):
492
            return "Stop current (A)"
493
        }
494
    }
495

            
Bogdan Timofte authored a month ago
496
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
497
        guard let chargedDevice else {
498
            return [:]
499
        }
500

            
501
        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
502
            result[sessionKind] = optionalCurrentText(
503
                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
504
            )
505
        }
Bogdan Timofte authored a month ago
506
    }
507

            
508
    private static func optionalCurrentText(_ value: Double?) -> String {
509
        guard let value else {
510
            return ""
511
        }
512
        return value.format(decimalDigits: 2)
513
    }
Bogdan Timofte authored a month ago
514

            
Bogdan Timofte authored a month ago
515
    private static func chargingSupportDescription(
516
        supportsWiredCharging: Bool,
517
        supportsWirelessCharging: Bool
518
    ) -> String {
519
        switch (supportsWiredCharging, supportsWirelessCharging) {
520
        case (true, true):
521
            return "Supports wired and wireless charging"
522
        case (true, false):
523
            return "Supports wired charging only"
524
        case (false, true):
525
            return "Supports wireless charging only"
526
        case (false, false):
527
            return "No charging method configured"
528
        }
529
    }
530

            
Bogdan Timofte authored a month ago
531
    private static func suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
532
        switch deviceClass {
533
        case .iphone:
534
            return .onOrOff
535
        case .watch:
536
            return .onOnly
537
        case .powerbank:
538
            return .offOnly
Bogdan Timofte authored a month ago
539
        case .charger, .other:
Bogdan Timofte authored a month ago
540
            return .onOrOff
Bogdan Timofte authored a month ago
541
        }
542
    }
Bogdan Timofte authored a month ago
543
}