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?
let kind: ChargedDeviceKind
@State private var name: String
@State private var deviceClass: ChargedDeviceClass
@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]
@State private var notes: String
init(
meterMACAddress: String?,
kind: ChargedDeviceKind,
chargedDevice: ChargedDeviceSummary? = nil
) {
self.meterMACAddress = meterMACAddress
self.chargedDevice = chargedDevice
let resolvedKind = chargedDevice?.kind ?? kind
self.kind = resolvedKind
let initialDeviceClass = chargedDevice?.deviceClass ?? (resolvedKind == .charger ? .charger : .iphone)
_name = State(initialValue: chargedDevice?.name ?? "")
_deviceClass = State(initialValue: initialDeviceClass)
_chargingStateAvailability = State(
initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
)
_supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
_supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
_wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
_completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
_notes = State(initialValue: chargedDevice?.notes ?? "")
}
var body: some View {
NavigationView {
Form {
identitySection
if kind == .device {
deviceChargeBehaviourSection
deviceChargingSupportSection
deviceCompletionSection
} else {
chargerInformationSection
}
notesSection
}
.navigationTitle(editorTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(saveButtonTitle) {
save()
}
.disabled(!canSave)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.onChange(of: deviceClass) { newValue in
guard kind == .device else {
return
}
applySuggestedChargingSupport(for: newValue)
}
.onAppear {
guard kind == .device, chargedDevice == nil else {
return
}
applySuggestedChargingSupport(for: deviceClass)
}
}
private var identitySection: some View {
Section(header: Text("Identity")) {
TextField(kind == .charger ? "Charger name" : "Name", text: $name)
if kind == .device {
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 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."
)
) {
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."
)
) {
Toggle("Supports wired charging", isOn: $supportsWiredCharging)
Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
if supportsWirelessCharging {
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(
"\(sessionKind.shortTitle) completion current (A)",
text: completionCurrentTextBinding(for: sessionKind)
)
.keyboardType(.decimalPad)
}
.padding(.vertical, 2)
}
}
}
}
private var chargerInformationSection: some View {
Section(
header: ContextInfoHeader(
title: "Charger",
message: "Chargers are edited separately from devices. Their charge-session metrics are learned automatically from wireless sessions."
)
) {
EmptyView()
}
}
private var notesSection: some View {
Section(header: Text("Notes")) {
TextField("Optional notes", text: $notes)
}
}
private var editorTitle: String {
if chargedDevice == nil {
return "New \(kind.title)"
}
return "Edit \(kind.title)"
}
private var saveButtonTitle: String {
chargedDevice == nil ? "Save" : "Update"
}
private var canSave: Bool {
let hasValidName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
guard kind == .device else {
return hasValidName
}
return hasValidName
&& (supportsWiredCharging || supportsWirelessCharging)
&& !hasInvalidCompletionCurrentEntry
}
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 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 didSave: Bool
if kind == .charger {
if let chargedDevice {
didSave = appData.updateCharger(
id: chargedDevice.id,
name: name,
notes: notes
)
} else {
didSave = appData.createCharger(
name: name,
notes: notes,
meterMACAddress: meterMACAddress
)
}
} else {
let configuredCompletionCurrents = parsedCompletionCurrents
if let chargedDevice {
didSave = appData.updateDevice(
id: chargedDevice.id,
name: name,
deviceClass: deviceClass,
chargingStateAvailability: chargingStateAvailability,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging,
wirelessChargingProfile: wirelessChargingProfile,
configuredCompletionCurrents: configuredCompletionCurrents,
notes: notes
)
} else {
didSave = appData.createDevice(
name: name,
deviceClass: deviceClass,
chargingStateAvailability: chargingStateAvailability,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging,
wirelessChargingProfile: wirelessChargingProfile,
configuredCompletionCurrents: configuredCompletionCurrents,
notes: notes,
meterMACAddress: meterMACAddress
)
}
}
if didSave {
dismiss()
}
}
private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
if chargedDevice != nil {
return
}
chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
switch deviceClass {
case .iphone:
supportsWiredCharging = true
supportsWirelessCharging = true
case .watch:
supportsWiredCharging = false
supportsWirelessCharging = true
case .powerbank:
supportsWiredCharging = true
supportsWirelessCharging = false
case .charger:
supportsWiredCharging = false
supportsWirelessCharging = true
case .other:
supportsWiredCharging = true
supportsWirelessCharging = false
}
}
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 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 suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
switch deviceClass {
case .iphone:
return .onOrOff
case .watch:
return .onOnly
case .powerbank:
return .offOnly
case .charger, .other:
return .onOnly
}
}
}