USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceEditorSheetView.swift
1 contributor
394 lines | 14.108kb
//
//  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
        }
    }
}