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