USB-Meter / USB Meter / Views / ChargedDevices / Sheets / Editors / ChargedDeviceEditorSheetView.swift
Newer Older
496 lines | 19.509kb
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 chargedDevice: ChargedDeviceSummary?
15

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

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

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

            
Bogdan Timofte authored a month ago
36
        _name = State(initialValue: chargedDevice?.name ?? "")
37
        _notes = State(initialValue: chargedDevice?.notes ?? "")
Bogdan Timofte authored a month ago
38

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

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

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

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

            
100
            if let chargedDevice {
101
                Text(chargedDevice.qrIdentifier)
102
                    .font(.caption.monospaced())
103
                    .foregroundColor(.secondary)
104
                    .textSelection(.enabled)
105
            }
106
        }
107
    }
108

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

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

            
130
            if let selectedTemplate {
131
                ChargedDeviceTemplateLabelView(
132
                    template: selectedTemplate,
133
                    iconPointSize: 18
134
                )
135
                .font(.subheadline.weight(.semibold))
136

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

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

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

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

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

            
209
            }
210

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

            
219
    private var deviceCompletionSection: some View {
220
        Section(
221
            header: ContextInfoHeader(
222
                title: "Charge Completion",
223
                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."
224
            )
225
        ) {
226
            if applicableSessionKinds.isEmpty {
227
                Text("Enable at least one charging method to configure stop currents.")
228
                    .font(.footnote)
229
                    .foregroundColor(.secondary)
230
            } else {
231
                ForEach(applicableSessionKinds) { sessionKind in
232
                    VStack(alignment: .leading, spacing: 6) {
233
                        TextField(
Bogdan Timofte authored a month ago
234
                            completionCurrentFieldLabel(for: sessionKind),
Bogdan Timofte authored a month ago
235
                            text: completionCurrentTextBinding(for: sessionKind)
236
                        )
237
                        .keyboardType(.decimalPad)
238
                    }
239
                    .padding(.vertical, 2)
240
                }
241
            }
242
        }
243
    }
244

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

            
Bogdan Timofte authored a month ago
251
    private var editorTitle: String {
Bogdan Timofte authored a month ago
252
        chargedDevice == nil ? "New Device" : "Edit Device"
Bogdan Timofte authored a month ago
253
    }
254

            
255
    private var saveButtonTitle: String {
256
        chargedDevice == nil ? "Save" : "Update"
257
    }
258

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

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

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

            
281
    private var selectedTemplate: ChargedDeviceTemplateDefinition? {
282
        ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID)
283
    }
284

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
337
    private func save() {
Bogdan Timofte authored a month ago
338
        let configuredCompletionCurrents = parsedCompletionCurrents
Bogdan Timofte authored a month ago
339
        let didSave: Bool
340

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

            
368
        if didSave {
369
            dismiss()
370
        }
371
    }
372

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

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

            
Bogdan Timofte authored a month ago
387
        deviceClass = newTemplate.deviceClass
388
        chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability(
389
            newTemplate.chargingStateAvailability
390
        )
391

            
392
        let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport(
393
            supportsWiredCharging: newTemplate.supportsWiredCharging,
394
            supportsWirelessCharging: newTemplate.supportsWirelessCharging
395
        )
396
        supportsWiredCharging = normalizedChargingSupport.wired
397
        supportsWirelessCharging = normalizedChargingSupport.wireless
398
        wirelessChargingProfile = newTemplate.wirelessChargingProfile
399
    }
400

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

            
416
        if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
417
            chargingStateAvailability = enforcedChargingStateAvailability
418
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
419
            chargingStateAvailability = deviceClass.defaultChargingStateAvailability
Bogdan Timofte authored a month ago
420
        }
421

            
422
        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
423
            supportsWiredCharging = enforcedChargingSupport.wired
424
            supportsWirelessCharging = enforcedChargingSupport.wireless
425
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
426
            let defaultChargingSupport = deviceClass.defaultChargingSupport
427
            supportsWiredCharging = defaultChargingSupport.wired
428
            supportsWirelessCharging = defaultChargingSupport.wireless
Bogdan Timofte authored a month ago
429
        }
430
    }
431

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

            
Bogdan Timofte authored a month ago
445
    private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
446
        let showsTransport = supportedChargingModes.count > 1
447
        let showsState = chargingStateAvailability.supportedModes.count > 1
448

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

            
Bogdan Timofte authored a month ago
461
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
462
        guard let chargedDevice else {
463
            return [:]
464
        }
465

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

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

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

            
Bogdan Timofte authored a month ago
496
}