// // 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 selectedTemplateID: String? @State private var lastAppliedTemplateID: String? @State private var chargingStateAvailability: ChargingStateAvailability @State private var supportsWiredCharging: Bool @State private var supportsWirelessCharging: Bool @State private var wirelessChargingProfile: WirelessChargingProfile @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 initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability( chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability ) let defaultChargingSupport = initialDeviceClass.defaultChargingSupport let initialChargingSupport = initialDeviceClass.normalizedChargingSupport( supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultChargingSupport.wired, supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultChargingSupport.wireless ) let initialTemplateID = chargedDevice?.deviceTemplateID _deviceClass = State(initialValue: initialDeviceClass) _selectedTemplateID = State(initialValue: initialTemplateID) _lastAppliedTemplateID = State(initialValue: initialTemplateID) _chargingStateAvailability = State(initialValue: initialChargingStateAvailability) _supportsWiredCharging = State(initialValue: initialChargingSupport.wired) _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless) _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi) _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)) } var body: some View { ChargedDeviceEditorScaffoldView( title: editorTitle, saveButtonTitle: saveButtonTitle, canSave: canSave, standalone: standalone, save: save ) { identitySection templateSection deviceChargeBehaviourSection deviceChargingSupportSection deviceCompletionSection notesSection } .onChange(of: deviceClass) { newValue in applyDeviceClassRules(for: newValue) } .onChange(of: selectedTemplateID) { newValue in applyTemplateSelection( previousTemplateID: lastAppliedTemplateID, newTemplateID: newValue ) lastAppliedTemplateID = newValue } .onAppear { applyDeviceClassRules(for: deviceClass) } } private var identitySection: some View { Section(header: Text("Identity")) { TextField("Name", text: $name) Picker("Class", selection: $deviceClass) { ForEach(ChargedDeviceClass.deviceCases) { deviceClass in Label(deviceClass.title, systemImage: deviceClass.symbolName) .tag(deviceClass) } } if let chargedDevice { Text(chargedDevice.qrIdentifier) .font(.caption.monospaced()) .foregroundColor(.secondary) .textSelection(.enabled) } } } private var templateSection: some View { Section( header: ContextInfoHeader( title: "Template", message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers." ) ) { Picker("Template", selection: $selectedTemplateID) { Text("Custom") .tag(String?.none) ForEach(groupedTemplates, id: \.group) { group in Section(group.group) { ForEach(group.templates) { template in Text(template.name) .tag(template.id as String?) } } } } if let selectedTemplate { ChargedDeviceTemplateLabelView( template: selectedTemplate, iconPointSize: 18 ) .font(.subheadline.weight(.semibold)) Text(selectedTemplate.capabilitySummary) .font(.caption) .foregroundColor(.secondary) } else { Text("Choose a template when you want a predefined icon and a starting charging setup.") .font(.caption) .foregroundColor(.secondary) } } } 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 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 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(WirelessChargingProfile.allCases) { profile in Text(profile.title) .tag(profile) } } } if supportedChargingModes.isEmpty { Text("Enable at least one charging method.") .font(.footnote) .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) } } 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 availableTemplates: [ChargedDeviceTemplateDefinition] { ChargedDeviceTemplateCatalog.shared.templates(for: .device) } private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] { Dictionary(grouping: availableTemplates, by: \.group) .keys .sorted() .map { group in ( group: group, templates: availableTemplates.filter { $0.group == group } ) } } private var selectedTemplate: ChargedDeviceTemplateDefinition? { ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID) } 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 { chargingTransportMode in chargingStateAvailability.supportedModes.map { chargingStateMode in ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ) } } } private var showsWirelessProfilePicker: Bool { supportsWirelessCharging && 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 } ) } private func save() { let configuredCompletionCurrents = parsedCompletionCurrents let didSave: Bool if let chargedDevice { didSave = appData.updateDevice( id: chargedDevice.id, name: name, deviceClass: deviceClass, templateID: selectedTemplateID, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) } else { didSave = appData.createDevice( name: name, deviceClass: deviceClass, templateID: selectedTemplateID, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) } if didSave { dismiss() } } private func applyTemplateSelection( previousTemplateID: String?, newTemplateID: String? ) { guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else { return } let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedName.isEmpty || trimmedName == previousTemplate?.name { name = newTemplate.name } deviceClass = newTemplate.deviceClass chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability( newTemplate.chargingStateAvailability ) let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport( supportsWiredCharging: newTemplate.supportsWiredCharging, supportsWirelessCharging: newTemplate.supportsWirelessCharging ) supportsWiredCharging = normalizedChargingSupport.wired supportsWirelessCharging = normalizedChargingSupport.wireless wirelessChargingProfile = newTemplate.wirelessChargingProfile } private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) { if let selectedTemplate { chargingStateAvailability = deviceClass.normalizedChargingStateAvailability( selectedTemplate.chargingStateAvailability ) let normalizedChargingSupport = deviceClass.normalizedChargingSupport( supportsWiredCharging: selectedTemplate.supportsWiredCharging, supportsWirelessCharging: selectedTemplate.supportsWirelessCharging ) supportsWiredCharging = normalizedChargingSupport.wired supportsWirelessCharging = normalizedChargingSupport.wireless wirelessChargingProfile = selectedTemplate.wirelessChargingProfile 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 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)" } } 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" } } }