1 contributor
//
// 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<String> {
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"
}
}
}