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 meterMACAddress: String?
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(
meterMACAddress: String?,
chargedDevice: ChargedDeviceSummary? = nil,
standalone: Bool = true
) {
self.meterMACAddress = meterMACAddress
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<String> {
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,
meterMACAddress: meterMACAddress
)
}
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"
}
}
}