USB-Meter / USB Meter / Views / ChargedDevices / Sheets / Editors / ChargedDeviceEditorSheetView.swift
Newer Older
623 lines | 26.011kb
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 selectedProfileID: String?
20
    @State private var lastAppliedProfileID: 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 hasInternalSubject: Bool
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(
Bogdan Timofte authored a month ago
31
        chargedDevice: ChargedDeviceSummary? = nil,
32
        standalone: Bool = true
Bogdan Timofte authored a month ago
33
    ) {
34
        self.chargedDevice = chargedDevice
Bogdan Timofte authored a month ago
35
        self.standalone = standalone
Bogdan Timofte authored a month ago
36

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

            
Bogdan Timofte authored a month ago
40
        let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone
Bogdan Timofte authored a month ago
41
        let initialProfileID = Self.resolveInitialProfileID(for: chargedDevice)
42
        let initialProfile = DeviceProfileCatalog.shared.profile(id: initialProfileID)
43

            
44
        let initialChargingStateAvailability: ChargingStateAvailability
45
        let initialSupportsWired: Bool
46
        let initialSupportsWireless: Bool
47
        let initialWirelessProfile: WirelessChargingProfile
48
        let initialHasInternalSubject: Bool
49

            
50
        if let initialProfile {
51
            let coerced = DeviceProfileValidator.coerce(
52
                state: DeviceProfileValidator.AppliedState(
53
                    chargingStateAvailability: chargedDevice?.chargingStateAvailability
54
                        ?? initialProfile.capChargingStateAvailability,
55
                    supportsWiredCharging: chargedDevice?.supportsWiredCharging
56
                        ?? initialProfile.capWiredCharging,
57
                    supportsWirelessCharging: chargedDevice?.supportsWirelessCharging
58
                        ?? initialProfile.capWirelessCharging,
59
                    wirelessChargingProfile: chargedDevice?.wirelessChargingProfile
60
                        ?? initialProfile.defaultWirelessChargingProfile
61
                        ?? .genericQi,
62
                    hasInternalSubject: chargedDevice?.hasInternalSubject ?? false
63
                ),
64
                to: initialProfile
65
            )
66
            initialChargingStateAvailability = coerced.chargingStateAvailability
67
            initialSupportsWired = coerced.supportsWiredCharging
68
            initialSupportsWireless = coerced.supportsWirelessCharging
69
            initialWirelessProfile = coerced.wirelessChargingProfile
70
            initialHasInternalSubject = coerced.hasInternalSubject
71
        } else {
72
            // Custom mode — use legacy class enforcement as a fallback.
73
            initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
74
                chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability
75
            )
76
            let defaultSupport = initialDeviceClass.defaultChargingSupport
77
            let normalizedSupport = initialDeviceClass.normalizedChargingSupport(
78
                supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultSupport.wired,
79
                supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultSupport.wireless
80
            )
81
            initialSupportsWired = normalizedSupport.wired
82
            initialSupportsWireless = normalizedSupport.wireless
83
            initialWirelessProfile = chargedDevice?.wirelessChargingProfile ?? .genericQi
84
            initialHasInternalSubject = chargedDevice?.hasInternalSubject ?? false
85
        }
86

            
Bogdan Timofte authored a month ago
87
        _deviceClass = State(initialValue: initialDeviceClass)
Bogdan Timofte authored a month ago
88
        _selectedProfileID = State(initialValue: initialProfileID)
89
        _lastAppliedProfileID = State(initialValue: initialProfileID)
Bogdan Timofte authored a month ago
90
        _chargingStateAvailability = State(initialValue: initialChargingStateAvailability)
Bogdan Timofte authored a month ago
91
        _supportsWiredCharging = State(initialValue: initialSupportsWired)
92
        _supportsWirelessCharging = State(initialValue: initialSupportsWireless)
93
        _wirelessChargingProfile = State(initialValue: initialWirelessProfile)
94
        _hasInternalSubject = State(initialValue: initialHasInternalSubject)
Bogdan Timofte authored a month ago
95
        _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
Bogdan Timofte authored a month ago
96
    }
97

            
98
    var body: some View {
Bogdan Timofte authored a month ago
99
        ChargedDeviceEditorScaffoldView(
100
            title: editorTitle,
101
            saveButtonTitle: saveButtonTitle,
102
            canSave: canSave,
103
            standalone: standalone,
104
            save: save
105
        ) {
Bogdan Timofte authored a month ago
106
            identitySection
Bogdan Timofte authored a month ago
107
            profileSection
108
            if selectedProfile == nil {
109
                customClassSection
110
            }
Bogdan Timofte authored a month ago
111
            deviceChargeBehaviourSection
112
            deviceChargingSupportSection
Bogdan Timofte authored a month ago
113
            if let profile = selectedProfile, profile.capHasInternalSubject {
114
                internalSubjectSection(for: profile)
115
            }
Bogdan Timofte authored a month ago
116
            deviceCompletionSection
117
            notesSection
118
        }
Bogdan Timofte authored a month ago
119
        .onChange(of: deviceClass) { newValue in
Bogdan Timofte authored a month ago
120
            applyDeviceClassRulesIfCustom(for: newValue)
Bogdan Timofte authored a month ago
121
        }
Bogdan Timofte authored a month ago
122
        .onChange(of: selectedProfileID) { newValue in
123
            applyProfileSelection(
124
                previousProfileID: lastAppliedProfileID,
125
                newProfileID: newValue
Bogdan Timofte authored a month ago
126
            )
Bogdan Timofte authored a month ago
127
            lastAppliedProfileID = newValue
Bogdan Timofte authored a month ago
128
        }
129
        .onAppear {
Bogdan Timofte authored a month ago
130
            if selectedProfile == nil {
131
                applyDeviceClassRulesIfCustom(for: deviceClass)
132
            }
Bogdan Timofte authored a month ago
133
        }
134
    }
135

            
Bogdan Timofte authored a month ago
136
    // MARK: - Sections
137

            
Bogdan Timofte authored a month ago
138
    private var identitySection: some View {
139
        Section(header: Text("Identity")) {
Bogdan Timofte authored a month ago
140
            TextField("Name", text: $name)
Bogdan Timofte authored a month ago
141

            
142
            if let chargedDevice {
143
                Text(chargedDevice.qrIdentifier)
144
                    .font(.caption.monospaced())
145
                    .foregroundColor(.secondary)
146
                    .textSelection(.enabled)
147
            }
148
        }
149
    }
150

            
Bogdan Timofte authored a month ago
151
    private var profileSection: some View {
Bogdan Timofte authored a month ago
152
        Section(
153
            header: ContextInfoHeader(
Bogdan Timofte authored a month ago
154
                title: "Profile",
155
                message: "Profiles describe what a device is and what it can do. Pick a catalog profile to apply its capabilities, or use Custom for a free-form configuration."
Bogdan Timofte authored a month ago
156
            )
157
        ) {
Bogdan Timofte authored a month ago
158
            Picker("Profile", selection: $selectedProfileID) {
159
                Text("Custom").tag(String?.none)
Bogdan Timofte authored a month ago
160

            
Bogdan Timofte authored a month ago
161
                ForEach(groupedProfiles, id: \.group) { group in
Bogdan Timofte authored a month ago
162
                    Section(group.group) {
Bogdan Timofte authored a month ago
163
                        ForEach(group.profiles) { profile in
164
                            Text(profile.name).tag(profile.id as String?)
Bogdan Timofte authored a month ago
165
                        }
166
                    }
167
                }
168
            }
169

            
Bogdan Timofte authored a month ago
170
            if let profile = selectedProfile {
171
                VStack(alignment: .leading, spacing: 4) {
172
                    Label(profile.name, systemImage: profile.icon.resolvedSystemSymbolName(
173
                        fallbackSystemName: profile.category.symbolName
174
                    ))
175
                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
176

            
Bogdan Timofte authored a month ago
177
                    Text(profile.capabilitySummary)
178
                        .font(.caption)
179
                        .foregroundColor(.secondary)
180
                }
Bogdan Timofte authored a month ago
181
            } else {
Bogdan Timofte authored a month ago
182
                Text("Custom devices use the class picker below for taxonomy and let you configure every capability manually.")
Bogdan Timofte authored a month ago
183
                    .font(.caption)
184
                    .foregroundColor(.secondary)
185
            }
186
        }
187
    }
188

            
Bogdan Timofte authored a month ago
189
    private var customClassSection: some View {
190
        Section(header: Text("Class")) {
191
            Picker("Class", selection: $deviceClass) {
192
                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
193
                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
194
                        .tag(deviceClass)
195
                }
196
            }
197
        }
198
    }
199

            
Bogdan Timofte authored a month ago
200
    private var deviceChargeBehaviourSection: some View {
201
        Section(
202
            header: ContextInfoHeader(
203
                title: "Charge Behaviour",
204
                message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
205
            )
206
        ) {
Bogdan Timofte authored a month ago
207
            if let profile = selectedProfile {
208
                if DeviceProfileValidator.chargingStateIsLocked(profile) {
209
                    VStack(alignment: .leading, spacing: 6) {
210
                        Label(profile.capChargingStateAvailability.title, systemImage: "lock.fill")
211
                            .font(.subheadline.weight(.semibold))
212
                        Text(profile.capChargingStateAvailability.description)
213
                            .font(.caption)
214
                            .foregroundColor(.secondary)
215
                    }
216
                } else {
217
                    Picker("Session Modes", selection: $chargingStateAvailability) {
218
                        ForEach(ChargingStateAvailability.allCases) { availability in
219
                            Text(availability.title).tag(availability)
220
                        }
221
                    }
222
                }
223
            } else if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
Bogdan Timofte authored a month ago
224
                VStack(alignment: .leading, spacing: 6) {
225
                    Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill")
226
                        .font(.subheadline.weight(.semibold))
227
                    Text(enforcedChargingStateAvailability.description)
228
                        .font(.caption)
229
                        .foregroundColor(.secondary)
230
                }
231
            } else {
232
                Picker("Session Modes", selection: $chargingStateAvailability) {
233
                    ForEach(ChargingStateAvailability.allCases) { availability in
Bogdan Timofte authored a month ago
234
                        Text(availability.title).tag(availability)
Bogdan Timofte authored a month ago
235
                    }
Bogdan Timofte authored a month ago
236
                }
237
            }
238
        }
239
    }
240

            
241
    private var deviceChargingSupportSection: some View {
242
        Section(
243
            header: ContextInfoHeader(
244
                title: "Charging Support",
245
                message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
246
            )
247
        ) {
Bogdan Timofte authored a month ago
248
            if let profile = selectedProfile {
249
                profileChargingSupportRows(for: profile)
250
            } else if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
Bogdan Timofte authored a month ago
251
                VStack(alignment: .leading, spacing: 6) {
252
                    Label(
253
                        Self.chargingSupportDescription(
254
                            supportsWiredCharging: enforcedChargingSupport.wired,
255
                            supportsWirelessCharging: enforcedChargingSupport.wireless
256
                        ),
257
                        systemImage: "lock.fill"
258
                    )
259
                    .font(.subheadline.weight(.semibold))
260

            
261
                    Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.")
262
                        .font(.caption)
263
                        .foregroundColor(.secondary)
264
                }
265
            } else {
266
                Toggle("Supports wired charging", isOn: $supportsWiredCharging)
267
                Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
268
            }
Bogdan Timofte authored a month ago
269

            
Bogdan Timofte authored a month ago
270
            if showsWirelessProfilePicker {
Bogdan Timofte authored a month ago
271
                Picker("Wireless profile", selection: $wirelessChargingProfile) {
Bogdan Timofte authored a month ago
272
                    ForEach(availableWirelessProfiles) { profile in
273
                        Text(profile.title).tag(profile)
Bogdan Timofte authored a month ago
274
                    }
275
                }
276
            }
277

            
278
            if supportedChargingModes.isEmpty {
279
                Text("Enable at least one charging method.")
280
                    .font(.footnote)
281
                    .foregroundColor(.secondary)
282
            }
283
        }
284
    }
285

            
Bogdan Timofte authored a month ago
286
    @ViewBuilder
287
    private func profileChargingSupportRows(for profile: DeviceProfileDefinition) -> some View {
288
        if DeviceProfileValidator.allowsTransportChoice(profile) {
289
            Toggle("Supports wired charging", isOn: $supportsWiredCharging)
290
            Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
291
        } else {
292
            VStack(alignment: .leading, spacing: 6) {
293
                Label(
294
                    Self.chargingSupportDescription(
295
                        supportsWiredCharging: profile.capWiredCharging,
296
                        supportsWirelessCharging: profile.capWirelessCharging
297
                    ),
298
                    systemImage: "lock.fill"
299
                )
300
                .font(.subheadline.weight(.semibold))
301

            
302
                Text("This profile only allows one charging transport.")
303
                    .font(.caption)
304
                    .foregroundColor(.secondary)
305
            }
306
        }
307
    }
308

            
309
    private func internalSubjectSection(for profile: DeviceProfileDefinition) -> some View {
310
        Section(
311
            header: ContextInfoHeader(
312
                title: "Internal Subject",
313
                message: "Charging cases (like AirPods) hold a removable subject. Toggle on while the subject is inside; off when the case is empty."
314
            )
315
        ) {
316
            Toggle("Subject is inside", isOn: $hasInternalSubject)
317
            Text("When off, sessions reflect only the case's own battery and parasitic load (e.g. BLE advertising).")
318
                .font(.caption)
319
                .foregroundColor(.secondary)
320
        }
321
    }
322

            
Bogdan Timofte authored a month ago
323
    private var deviceCompletionSection: some View {
324
        Section(
325
            header: ContextInfoHeader(
326
                title: "Charge Completion",
327
                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."
328
            )
329
        ) {
330
            if applicableSessionKinds.isEmpty {
331
                Text("Enable at least one charging method to configure stop currents.")
332
                    .font(.footnote)
333
                    .foregroundColor(.secondary)
334
            } else {
335
                ForEach(applicableSessionKinds) { sessionKind in
336
                    VStack(alignment: .leading, spacing: 6) {
337
                        TextField(
Bogdan Timofte authored a month ago
338
                            completionCurrentFieldLabel(for: sessionKind),
Bogdan Timofte authored a month ago
339
                            text: completionCurrentTextBinding(for: sessionKind)
340
                        )
341
                        .keyboardType(.decimalPad)
342
                    }
343
                    .padding(.vertical, 2)
344
                }
345
            }
346
        }
347
    }
348

            
349
    private var notesSection: some View {
350
        Section(header: Text("Notes")) {
351
            TextField("Optional notes", text: $notes)
352
        }
353
    }
354

            
Bogdan Timofte authored a month ago
355
    // MARK: - Computed
356

            
Bogdan Timofte authored a month ago
357
    private var editorTitle: String {
Bogdan Timofte authored a month ago
358
        chargedDevice == nil ? "New Device" : "Edit Device"
Bogdan Timofte authored a month ago
359
    }
360

            
361
    private var saveButtonTitle: String {
362
        chargedDevice == nil ? "Save" : "Update"
363
    }
364

            
Bogdan Timofte authored a month ago
365
    private var canSave: Bool {
Bogdan Timofte authored a month ago
366
        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Bogdan Timofte authored a month ago
367
            && (supportsWiredCharging || supportsWirelessCharging)
368
            && !hasInvalidCompletionCurrentEntry
369
    }
370

            
Bogdan Timofte authored a month ago
371
    private var availableProfiles: [DeviceProfileDefinition] {
372
        DeviceProfileCatalog.shared.profiles.filter { $0.category.kind == .device }
Bogdan Timofte authored a month ago
373
    }
374

            
Bogdan Timofte authored a month ago
375
    private var groupedProfiles: [(group: String, profiles: [DeviceProfileDefinition])] {
376
        Dictionary(grouping: availableProfiles, by: \.group)
Bogdan Timofte authored a month ago
377
            .keys
378
            .sorted()
379
            .map { group in
Bogdan Timofte authored a month ago
380
                (group: group, profiles: availableProfiles.filter { $0.group == group })
Bogdan Timofte authored a month ago
381
            }
382
    }
383

            
Bogdan Timofte authored a month ago
384
    private var selectedProfile: DeviceProfileDefinition? {
385
        DeviceProfileCatalog.shared.profile(id: selectedProfileID)
Bogdan Timofte authored a month ago
386
    }
387

            
Bogdan Timofte authored a month ago
388
    private var supportedChargingModes: [ChargingTransportMode] {
389
        var modes: [ChargingTransportMode] = []
Bogdan Timofte authored a month ago
390
        if supportsWiredCharging { modes.append(.wired) }
391
        if supportsWirelessCharging { modes.append(.wireless) }
Bogdan Timofte authored a month ago
392
        return modes
393
    }
394

            
Bogdan Timofte authored a month ago
395
    private var applicableSessionKinds: [ChargeSessionKind] {
Bogdan Timofte authored a month ago
396
        supportedChargingModes.flatMap { transportMode in
397
            chargingStateAvailability.supportedModes.map { stateMode in
398
                ChargeSessionKind(chargingTransportMode: transportMode, chargingStateMode: stateMode)
Bogdan Timofte authored a month ago
399
            }
400
        }
401
    }
402

            
Bogdan Timofte authored a month ago
403
    private var availableWirelessProfiles: [WirelessChargingProfile] {
404
        if let profile = selectedProfile, !profile.capWirelessProfiles.isEmpty {
405
            return profile.capWirelessProfiles
406
        }
407
        return WirelessChargingProfile.allCases
408
    }
409

            
Bogdan Timofte authored a month ago
410
    private var showsWirelessProfilePicker: Bool {
Bogdan Timofte authored a month ago
411
        guard supportsWirelessCharging else { return false }
412
        if let profile = selectedProfile {
413
            return DeviceProfileValidator.allowsWirelessProfileChoice(profile)
414
                && supportedChargingModes.count > 1
415
        }
416
        return deviceClass != .watch && supportedChargingModes.count > 1
Bogdan Timofte authored a month ago
417
    }
418

            
Bogdan Timofte authored a month ago
419
    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
420
        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
421
            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
422
                return
423
            }
424
            result[sessionKind] = value
425
        }
426
    }
427

            
428
    private var hasInvalidCompletionCurrentEntry: Bool {
429
        applicableSessionKinds.contains { sessionKind in
430
            let text = completionCurrentTexts[sessionKind] ?? ""
431
            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
432
            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
433
        }
434
    }
435

            
436
    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
437
        Binding(
438
            get: { completionCurrentTexts[sessionKind] ?? "" },
439
            set: { completionCurrentTexts[sessionKind] = $0 }
440
        )
441
    }
442

            
Bogdan Timofte authored a month ago
443
    // MARK: - Save & application
444

            
Bogdan Timofte authored a month ago
445
    private func save() {
Bogdan Timofte authored a month ago
446
        let configuredCompletionCurrents = parsedCompletionCurrents
Bogdan Timofte authored a month ago
447
        let resolvedDeviceClass: ChargedDeviceClass
448
        let resolvedTemplateID: String?
449

            
450
        if let profile = selectedProfile {
451
            // Derive legacy fields from the profile so Phase 1 readers still work.
452
            resolvedDeviceClass = mapCategoryToLegacyClass(profile.category)
453
            resolvedTemplateID = profile.id
454
        } else {
455
            resolvedDeviceClass = deviceClass
456
            resolvedTemplateID = chargedDevice?.deviceTemplateID
457
        }
458

            
Bogdan Timofte authored a month ago
459
        let didSave: Bool
460

            
Bogdan Timofte authored a month ago
461
        if let chargedDevice {
462
            didSave = appData.updateDevice(
463
                id: chargedDevice.id,
464
                name: name,
Bogdan Timofte authored a month ago
465
                deviceClass: resolvedDeviceClass,
466
                templateID: resolvedTemplateID,
467
                profileID: selectedProfileID,
468
                hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
469
                chargingStateAvailability: chargingStateAvailability,
470
                supportsWiredCharging: supportsWiredCharging,
471
                supportsWirelessCharging: supportsWirelessCharging,
472
                wirelessChargingProfile: wirelessChargingProfile,
473
                configuredCompletionCurrents: configuredCompletionCurrents,
474
                notes: notes
475
            )
Bogdan Timofte authored a month ago
476
        } else {
Bogdan Timofte authored a month ago
477
            didSave = appData.createDevice(
478
                name: name,
Bogdan Timofte authored a month ago
479
                deviceClass: resolvedDeviceClass,
480
                templateID: resolvedTemplateID,
481
                profileID: selectedProfileID,
482
                hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
483
                chargingStateAvailability: chargingStateAvailability,
484
                supportsWiredCharging: supportsWiredCharging,
485
                supportsWirelessCharging: supportsWirelessCharging,
486
                wirelessChargingProfile: wirelessChargingProfile,
487
                configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
488
                notes: notes
Bogdan Timofte authored a month ago
489
            )
Bogdan Timofte authored a month ago
490
        }
491

            
492
        if didSave {
493
            dismiss()
494
        }
495
    }
496

            
Bogdan Timofte authored a month ago
497
    private func applyProfileSelection(
498
        previousProfileID: String?,
499
        newProfileID: String?
Bogdan Timofte authored a month ago
500
    ) {
Bogdan Timofte authored a month ago
501
        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
502
        let previousProfile = DeviceProfileCatalog.shared.profile(id: previousProfileID)
503

            
504
        guard let newProfile = DeviceProfileCatalog.shared.profile(id: newProfileID) else {
505
            // Switched to "Custom" — keep current state, fall back to legacy class rules.
506
            if !trimmedName.isEmpty, trimmedName == previousProfile?.name {
507
                name = ""
508
            }
509
            applyDeviceClassRulesIfCustom(for: deviceClass)
Bogdan Timofte authored a month ago
510
            return
511
        }
512

            
Bogdan Timofte authored a month ago
513
        if trimmedName.isEmpty || trimmedName == previousProfile?.name {
514
            name = newProfile.name
Bogdan Timofte authored a month ago
515
        }
Bogdan Timofte authored a month ago
516

            
Bogdan Timofte authored a month ago
517
        let canonical = DeviceProfileValidator.canonicalState(for: newProfile)
518
        chargingStateAvailability = canonical.chargingStateAvailability
519
        supportsWiredCharging = canonical.supportsWiredCharging
520
        supportsWirelessCharging = canonical.supportsWirelessCharging
521
        wirelessChargingProfile = canonical.wirelessChargingProfile
522
        if !newProfile.capHasInternalSubject {
523
            hasInternalSubject = false
524
        }
525
        deviceClass = mapCategoryToLegacyClass(newProfile.category)
Bogdan Timofte authored a month ago
526
    }
527

            
Bogdan Timofte authored a month ago
528
    private func applyDeviceClassRulesIfCustom(for deviceClass: ChargedDeviceClass) {
529
        guard selectedProfile == nil else { return }
Bogdan Timofte authored a month ago
530

            
531
        if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
532
            chargingStateAvailability = enforcedChargingStateAvailability
533
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
534
            chargingStateAvailability = deviceClass.defaultChargingStateAvailability
Bogdan Timofte authored a month ago
535
        }
536

            
537
        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
538
            supportsWiredCharging = enforcedChargingSupport.wired
539
            supportsWirelessCharging = enforcedChargingSupport.wireless
540
        } else if chargedDevice == nil {
Bogdan Timofte authored a month ago
541
            let defaultChargingSupport = deviceClass.defaultChargingSupport
542
            supportsWiredCharging = defaultChargingSupport.wired
543
            supportsWirelessCharging = defaultChargingSupport.wireless
Bogdan Timofte authored a month ago
544
        }
545
    }
546

            
Bogdan Timofte authored a month ago
547
    private func mapCategoryToLegacyClass(_ category: ProfileCategory) -> ChargedDeviceClass {
548
        switch category {
549
        case .phone: return .iphone
550
        case .watch: return .watch
551
        case .powerbank: return .powerbank
552
        case .charger: return .charger
553
        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other:
554
            return .other
555
        }
556
    }
557

            
Bogdan Timofte authored a month ago
558
    private func parsedOptionalCurrent(_ text: String) -> Double? {
559
        let normalized = text
560
            .trimmingCharacters(in: .whitespacesAndNewlines)
561
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
562
        guard !normalized.isEmpty else { return nil }
563
        guard let value = Double(normalized), value > 0 else { return nil }
Bogdan Timofte authored a month ago
564
        return value
565
    }
566

            
Bogdan Timofte authored a month ago
567
    private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
568
        let showsTransport = supportedChargingModes.count > 1
569
        let showsState = chargingStateAvailability.supportedModes.count > 1
570

            
571
        switch (showsTransport, showsState) {
572
        case (true, true):
573
            return "\(sessionKind.shortTitle) completion current (A)"
574
        case (true, false):
575
            return "\(sessionKind.chargingTransportMode.title) completion current (A)"
576
        case (false, true):
577
            return "\(sessionKind.chargingStateMode.title) completion current (A)"
578
        case (false, false):
579
            return "Stop current (A)"
580
        }
581
    }
582

            
Bogdan Timofte authored a month ago
583
    // MARK: - Helpers
584

            
585
    private static func resolveInitialProfileID(for chargedDevice: ChargedDeviceSummary?) -> String? {
586
        guard let chargedDevice else { return nil }
587
        if let profileID = chargedDevice.profileID,
588
           DeviceProfileCatalog.shared.profile(id: profileID) != nil {
589
            return profileID
Bogdan Timofte authored a month ago
590
        }
Bogdan Timofte authored a month ago
591
        if let templateID = chargedDevice.deviceTemplateID,
592
           DeviceProfileCatalog.shared.profile(id: templateID) != nil {
593
            return templateID
594
        }
595
        return nil
596
    }
Bogdan Timofte authored a month ago
597

            
Bogdan Timofte authored a month ago
598
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
599
        guard let chargedDevice else { return [:] }
Bogdan Timofte authored a month ago
600
        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
601
            result[sessionKind] = optionalCurrentText(
602
                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
603
            )
604
        }
Bogdan Timofte authored a month ago
605
    }
606

            
607
    private static func optionalCurrentText(_ value: Double?) -> String {
Bogdan Timofte authored a month ago
608
        guard let value else { return "" }
Bogdan Timofte authored a month ago
609
        return value.format(decimalDigits: 2)
610
    }
Bogdan Timofte authored a month ago
611

            
Bogdan Timofte authored a month ago
612
    private static func chargingSupportDescription(
613
        supportsWiredCharging: Bool,
614
        supportsWirelessCharging: Bool
615
    ) -> String {
616
        switch (supportsWiredCharging, supportsWirelessCharging) {
Bogdan Timofte authored a month ago
617
        case (true, true): return "Supports wired and wireless charging"
618
        case (true, false): return "Supports wired charging only"
619
        case (false, true): return "Supports wireless charging only"
620
        case (false, false): return "No charging method configured"
Bogdan Timofte authored a month ago
621
        }
622
    }
Bogdan Timofte authored a month ago
623
}