USB-Meter / USB Meter / Views / ChargedDevices / Sheets / Editors / ChargedDeviceEditorSheetView.swift
1 contributor
500 lines | 19.673kb
//
//  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"
        }
    }

}