USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceEditorSheetView.swift
1 contributor
587 lines | 22.586kb
//
//  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 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]
    @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)
        let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
            chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
        )
        let initialChargingSupport = initialDeviceClass.normalizedChargingSupport(
            supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? true,
            supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? true
        )
        let initialTemplateID = chargedDevice?.deviceTemplateID
        _name = State(initialValue: chargedDevice?.name ?? "")
        _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))
        _notes = State(initialValue: chargedDevice?.notes ?? "")
    }

    var body: some View {
        NavigationView {
            Form {
                identitySection
                templateSection

                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
            }
            applyDeviceClassRules(for: newValue)
        }
        .onChange(of: selectedTemplateID) { newValue in
            applyTemplateSelection(
                previousTemplateID: lastAppliedTemplateID,
                newTemplateID: newValue
            )
            lastAppliedTemplateID = newValue
        }
        .onAppear {
            guard kind == .device else {
                return
            }
            applyDeviceClassRules(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 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 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 availableTemplates: [ChargedDeviceTemplateDefinition] {
        ChargedDeviceTemplateCatalog.shared.templates(for: kind)
    }

    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 didSave: Bool

        if kind == .charger {
            if let chargedDevice {
                didSave = appData.updateCharger(
                    id: chargedDevice.id,
                    name: name,
                    templateID: selectedTemplateID,
                    notes: notes
                )
            } else {
                didSave = appData.createCharger(
                    name: name,
                    templateID: selectedTemplateID,
                    notes: notes,
                    meterMACAddress: meterMACAddress
                )
            }
        } else {
            let configuredCompletionCurrents = parsedCompletionCurrents
            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 = Self.suggestedChargingStateAvailability(for: deviceClass)
        }

        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
            supportsWiredCharging = enforcedChargingSupport.wired
            supportsWirelessCharging = enforcedChargingSupport.wireless
        } else if chargedDevice == nil {
            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 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"
        }
    }

    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
        }
    }
}