// // ChargedDeviceEditorSheetView.swift // USB Meter // // Created by Codex on 10/04/2026. // import SwiftUI struct ChargedDeviceEditorSheetView: View { @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss let chargedDevice: ChargedDeviceSummary? @State private var name: String @State private var notes: String @State private var deviceClass: ChargedDeviceClass @State private var selectedProfileID: String? @State private var lastAppliedProfileID: String? @State private var chargingStateAvailability: ChargingStateAvailability @State private var supportsWiredCharging: Bool @State private var supportsWirelessCharging: Bool @State private var wirelessChargingProfile: WirelessChargingProfile @State private var hasInternalSubject: Bool @State private var completionCurrentTexts: [ChargeSessionKind: String] let standalone: Bool init( chargedDevice: ChargedDeviceSummary? = nil, standalone: Bool = true ) { self.chargedDevice = chargedDevice self.standalone = standalone _name = State(initialValue: chargedDevice?.name ?? "") _notes = State(initialValue: chargedDevice?.notes ?? "") let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone let initialProfileID = Self.resolveInitialProfileID(for: chargedDevice) let initialProfile = DeviceProfileCatalog.shared.profile(id: initialProfileID) let initialChargingStateAvailability: ChargingStateAvailability let initialSupportsWired: Bool let initialSupportsWireless: Bool let initialWirelessProfile: WirelessChargingProfile let initialHasInternalSubject: Bool if let initialProfile { let coerced = DeviceProfileValidator.coerce( state: DeviceProfileValidator.AppliedState( chargingStateAvailability: chargedDevice?.chargingStateAvailability ?? initialProfile.capChargingStateAvailability, supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? initialProfile.capWiredCharging, supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? initialProfile.capWirelessCharging, wirelessChargingProfile: chargedDevice?.wirelessChargingProfile ?? initialProfile.defaultWirelessChargingProfile ?? .genericQi, hasInternalSubject: chargedDevice?.hasInternalSubject ?? false ), to: initialProfile ) initialChargingStateAvailability = coerced.chargingStateAvailability initialSupportsWired = coerced.supportsWiredCharging initialSupportsWireless = coerced.supportsWirelessCharging initialWirelessProfile = coerced.wirelessChargingProfile initialHasInternalSubject = coerced.hasInternalSubject } else { // Custom mode — use legacy class enforcement as a fallback. initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability( chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability ) let defaultSupport = initialDeviceClass.defaultChargingSupport let normalizedSupport = initialDeviceClass.normalizedChargingSupport( supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultSupport.wired, supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultSupport.wireless ) initialSupportsWired = normalizedSupport.wired initialSupportsWireless = normalizedSupport.wireless initialWirelessProfile = chargedDevice?.wirelessChargingProfile ?? .genericQi initialHasInternalSubject = chargedDevice?.hasInternalSubject ?? false } _deviceClass = State(initialValue: initialDeviceClass) _selectedProfileID = State(initialValue: initialProfileID) _lastAppliedProfileID = State(initialValue: initialProfileID) _chargingStateAvailability = State(initialValue: initialChargingStateAvailability) _supportsWiredCharging = State(initialValue: initialSupportsWired) _supportsWirelessCharging = State(initialValue: initialSupportsWireless) _wirelessChargingProfile = State(initialValue: initialWirelessProfile) _hasInternalSubject = State(initialValue: initialHasInternalSubject) _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)) } var body: some View { ChargedDeviceEditorScaffoldView( title: editorTitle, saveButtonTitle: saveButtonTitle, canSave: canSave, standalone: standalone, save: save ) { identitySection profileSection if selectedProfile == nil { customClassSection } deviceChargeBehaviourSection deviceChargingSupportSection if let profile = selectedProfile, profile.capHasInternalSubject { internalSubjectSection(for: profile) } deviceCompletionSection notesSection } .onChange(of: deviceClass) { newValue in applyDeviceClassRulesIfCustom(for: newValue) } .onChange(of: selectedProfileID) { newValue in applyProfileSelection( previousProfileID: lastAppliedProfileID, newProfileID: newValue ) lastAppliedProfileID = newValue } .onAppear { if selectedProfile == nil { applyDeviceClassRulesIfCustom(for: deviceClass) } } } // MARK: - Sections private var identitySection: some View { Section(header: Text("Identity")) { TextField("Name", text: $name) if let chargedDevice { Text(chargedDevice.qrIdentifier) .font(.caption.monospaced()) .foregroundColor(.secondary) .textSelection(.enabled) } } } private var profileSection: some View { Section( header: ContextInfoHeader( title: "Profile", 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." ) ) { Picker("Profile", selection: $selectedProfileID) { Text("Custom").tag(String?.none) ForEach(groupedProfiles, id: \.group) { group in Section(group.group) { ForEach(group.profiles) { profile in Text(profile.name).tag(profile.id as String?) } } } } if let profile = selectedProfile { VStack(alignment: .leading, spacing: 4) { Label(profile.name, systemImage: profile.icon.resolvedSystemSymbolName( fallbackSystemName: profile.category.symbolName )) .font(.subheadline.weight(.semibold)) Text(profile.capabilitySummary) .font(.caption) .foregroundColor(.secondary) } } else { Text("Custom devices use the class picker below for taxonomy and let you configure every capability manually.") .font(.caption) .foregroundColor(.secondary) } } } private var customClassSection: some View { Section(header: Text("Class")) { Picker("Class", selection: $deviceClass) { ForEach(ChargedDeviceClass.deviceCases) { deviceClass in Label(deviceClass.title, systemImage: deviceClass.symbolName) .tag(deviceClass) } } } } private var deviceChargeBehaviourSection: some View { Section( header: ContextInfoHeader( title: "Charge Behaviour", message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state." ) ) { if let profile = selectedProfile { if DeviceProfileValidator.chargingStateIsLocked(profile) { VStack(alignment: .leading, spacing: 6) { Label(profile.capChargingStateAvailability.title, systemImage: "lock.fill") .font(.subheadline.weight(.semibold)) Text(profile.capChargingStateAvailability.description) .font(.caption) .foregroundColor(.secondary) } } else { Picker("Session Modes", selection: $chargingStateAvailability) { ForEach(ChargingStateAvailability.allCases) { availability in Text(availability.title).tag(availability) } } } } else if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability { VStack(alignment: .leading, spacing: 6) { Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill") .font(.subheadline.weight(.semibold)) Text(enforcedChargingStateAvailability.description) .font(.caption) .foregroundColor(.secondary) } } else { Picker("Session Modes", selection: $chargingStateAvailability) { ForEach(ChargingStateAvailability.allCases) { availability in Text(availability.title).tag(availability) } } } } } private var deviceChargingSupportSection: some View { Section( header: ContextInfoHeader( title: "Charging Support", message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate." ) ) { if let profile = selectedProfile { profileChargingSupportRows(for: profile) } else if let enforcedChargingSupport = deviceClass.enforcedChargingSupport { VStack(alignment: .leading, spacing: 6) { Label( Self.chargingSupportDescription( supportsWiredCharging: enforcedChargingSupport.wired, supportsWirelessCharging: enforcedChargingSupport.wireless ), systemImage: "lock.fill" ) .font(.subheadline.weight(.semibold)) Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.") .font(.caption) .foregroundColor(.secondary) } } else { Toggle("Supports wired charging", isOn: $supportsWiredCharging) Toggle("Supports wireless charging", isOn: $supportsWirelessCharging) } if showsWirelessProfilePicker { Picker("Wireless profile", selection: $wirelessChargingProfile) { ForEach(availableWirelessProfiles) { profile in Text(profile.title).tag(profile) } } } if supportedChargingModes.isEmpty { Text("Enable at least one charging method.") .font(.footnote) .foregroundColor(.secondary) } } } @ViewBuilder private func profileChargingSupportRows(for profile: DeviceProfileDefinition) -> some View { if DeviceProfileValidator.allowsTransportChoice(profile) { Toggle("Supports wired charging", isOn: $supportsWiredCharging) Toggle("Supports wireless charging", isOn: $supportsWirelessCharging) } else { VStack(alignment: .leading, spacing: 6) { Label( Self.chargingSupportDescription( supportsWiredCharging: profile.capWiredCharging, supportsWirelessCharging: profile.capWirelessCharging ), systemImage: "lock.fill" ) .font(.subheadline.weight(.semibold)) Text("This profile only allows one charging transport.") .font(.caption) .foregroundColor(.secondary) } } } private func internalSubjectSection(for profile: DeviceProfileDefinition) -> some View { Section( header: ContextInfoHeader( title: "Internal Subject", message: "Charging cases (like AirPods) hold a removable subject. Toggle on while the subject is inside; off when the case is empty." ) ) { Toggle("Subject is inside", isOn: $hasInternalSubject) Text("When off, sessions reflect only the case's own battery and parasitic load (e.g. BLE advertising).") .font(.caption) .foregroundColor(.secondary) } } private var deviceCompletionSection: some View { Section( header: ContextInfoHeader( title: "Charge Completion", 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." ) ) { if applicableSessionKinds.isEmpty { Text("Enable at least one charging method to configure stop currents.") .font(.footnote) .foregroundColor(.secondary) } else { ForEach(applicableSessionKinds) { sessionKind in VStack(alignment: .leading, spacing: 6) { TextField( completionCurrentFieldLabel(for: sessionKind), text: completionCurrentTextBinding(for: sessionKind) ) .keyboardType(.decimalPad) } .padding(.vertical, 2) } } } } private var notesSection: some View { Section(header: Text("Notes")) { TextField("Optional notes", text: $notes) } } // MARK: - Computed private var editorTitle: String { chargedDevice == nil ? "New Device" : "Edit Device" } private var saveButtonTitle: String { chargedDevice == nil ? "Save" : "Update" } private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (supportsWiredCharging || supportsWirelessCharging) && !hasInvalidCompletionCurrentEntry } private var availableProfiles: [DeviceProfileDefinition] { DeviceProfileCatalog.shared.profiles.filter { $0.category.kind == .device } } private var groupedProfiles: [(group: String, profiles: [DeviceProfileDefinition])] { Dictionary(grouping: availableProfiles, by: \.group) .keys .sorted() .map { group in (group: group, profiles: availableProfiles.filter { $0.group == group }) } } private var selectedProfile: DeviceProfileDefinition? { DeviceProfileCatalog.shared.profile(id: selectedProfileID) } private var supportedChargingModes: [ChargingTransportMode] { var modes: [ChargingTransportMode] = [] if supportsWiredCharging { modes.append(.wired) } if supportsWirelessCharging { modes.append(.wireless) } return modes } private var applicableSessionKinds: [ChargeSessionKind] { supportedChargingModes.flatMap { transportMode in chargingStateAvailability.supportedModes.map { stateMode in ChargeSessionKind(chargingTransportMode: transportMode, chargingStateMode: stateMode) } } } private var availableWirelessProfiles: [WirelessChargingProfile] { if let profile = selectedProfile, !profile.capWirelessProfiles.isEmpty { return profile.capWirelessProfiles } return WirelessChargingProfile.allCases } private var showsWirelessProfilePicker: Bool { guard supportsWirelessCharging else { return false } if let profile = selectedProfile { return DeviceProfileValidator.allowsWirelessProfileChoice(profile) && supportedChargingModes.count > 1 } return deviceClass != .watch && supportedChargingModes.count > 1 } private var parsedCompletionCurrents: [ChargeSessionKind: Double] { applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else { return } result[sessionKind] = value } } private var hasInvalidCompletionCurrentEntry: Bool { applicableSessionKinds.contains { sessionKind in let text = completionCurrentTexts[sessionKind] ?? "" let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) return !normalized.isEmpty && parsedOptionalCurrent(text) == nil } } private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding { Binding( get: { completionCurrentTexts[sessionKind] ?? "" }, set: { completionCurrentTexts[sessionKind] = $0 } ) } // MARK: - Save & application private func save() { let configuredCompletionCurrents = parsedCompletionCurrents let resolvedDeviceClass: ChargedDeviceClass let resolvedTemplateID: String? if let profile = selectedProfile { // Derive legacy fields from the profile so Phase 1 readers still work. resolvedDeviceClass = mapCategoryToLegacyClass(profile.category) resolvedTemplateID = profile.id } else { resolvedDeviceClass = deviceClass resolvedTemplateID = chargedDevice?.deviceTemplateID } let didSave: Bool if let chargedDevice { didSave = appData.updateDevice( id: chargedDevice.id, name: name, deviceClass: resolvedDeviceClass, templateID: resolvedTemplateID, profileID: selectedProfileID, hasInternalSubject: hasInternalSubject, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) } else { didSave = appData.createDevice( name: name, deviceClass: resolvedDeviceClass, templateID: resolvedTemplateID, profileID: selectedProfileID, hasInternalSubject: hasInternalSubject, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) } if didSave { dismiss() } } private func applyProfileSelection( previousProfileID: String?, newProfileID: String? ) { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let previousProfile = DeviceProfileCatalog.shared.profile(id: previousProfileID) guard let newProfile = DeviceProfileCatalog.shared.profile(id: newProfileID) else { // Switched to "Custom" — keep current state, fall back to legacy class rules. if !trimmedName.isEmpty, trimmedName == previousProfile?.name { name = "" } applyDeviceClassRulesIfCustom(for: deviceClass) return } if trimmedName.isEmpty || trimmedName == previousProfile?.name { name = newProfile.name } let canonical = DeviceProfileValidator.canonicalState(for: newProfile) chargingStateAvailability = canonical.chargingStateAvailability supportsWiredCharging = canonical.supportsWiredCharging supportsWirelessCharging = canonical.supportsWirelessCharging wirelessChargingProfile = canonical.wirelessChargingProfile if !newProfile.capHasInternalSubject { hasInternalSubject = false } deviceClass = mapCategoryToLegacyClass(newProfile.category) } private func applyDeviceClassRulesIfCustom(for deviceClass: ChargedDeviceClass) { guard selectedProfile == nil else { return } if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability { chargingStateAvailability = enforcedChargingStateAvailability } else if chargedDevice == nil { chargingStateAvailability = deviceClass.defaultChargingStateAvailability } if let enforcedChargingSupport = deviceClass.enforcedChargingSupport { supportsWiredCharging = enforcedChargingSupport.wired supportsWirelessCharging = enforcedChargingSupport.wireless } else if chargedDevice == nil { let defaultChargingSupport = deviceClass.defaultChargingSupport supportsWiredCharging = defaultChargingSupport.wired supportsWirelessCharging = defaultChargingSupport.wireless } } private func mapCategoryToLegacyClass(_ category: ProfileCategory) -> ChargedDeviceClass { switch category { case .phone: return .iphone case .watch: return .watch case .powerbank: return .powerbank case .charger: return .charger case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other } } private func parsedOptionalCurrent(_ text: String) -> Double? { let normalized = text .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard !normalized.isEmpty else { return nil } guard let value = Double(normalized), value > 0 else { return nil } return value } private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String { let showsTransport = supportedChargingModes.count > 1 let showsState = chargingStateAvailability.supportedModes.count > 1 switch (showsTransport, showsState) { case (true, true): return "\(sessionKind.shortTitle) completion current (A)" case (true, false): return "\(sessionKind.chargingTransportMode.title) completion current (A)" case (false, true): return "\(sessionKind.chargingStateMode.title) completion current (A)" case (false, false): return "Stop current (A)" } } // MARK: - Helpers private static func resolveInitialProfileID(for chargedDevice: ChargedDeviceSummary?) -> String? { guard let chargedDevice else { return nil } if let profileID = chargedDevice.profileID, DeviceProfileCatalog.shared.profile(id: profileID) != nil { return profileID } if let templateID = chargedDevice.deviceTemplateID, DeviceProfileCatalog.shared.profile(id: templateID) != nil { return templateID } return nil } private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] { guard let chargedDevice else { return [:] } return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in result[sessionKind] = optionalCurrentText( chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) ) } } private static func optionalCurrentText(_ value: Double?) -> String { guard let value else { return "" } return value.format(decimalDigits: 2) } private static func chargingSupportDescription( supportsWiredCharging: Bool, supportsWirelessCharging: Bool ) -> String { switch (supportsWiredCharging, supportsWirelessCharging) { case (true, true): return "Supports wired and wireless charging" case (true, false): return "Supports wired charging only" case (false, true): return "Supports wireless charging only" case (false, false): return "No charging method configured" } } }