USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
1 contributor
753 lines | 28.751kb
//
//  MeterChargeRecordTabView.swift
//  USB Meter
//

import SwiftUI

struct MeterChargeRecordTabView: View, Equatable {
    static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
        true
    }

    var body: some View {
        MeterChargeRecordContentView()
    }
}

struct MeterChargeRecordContentView: View {
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
        case known
        case unknown
        case flat

        var id: String { rawValue }

        var title: String {
            switch self {
            case .known:   return "Known"
            case .unknown: return "Unknown"
            case .flat:    return "Flat"
            }
        }
    }

    private enum ActiveMode: Hashable {
        case chargeSession
        case standbyPower
    }

    private enum SessionStartRequirement: Identifiable {
        case existingSession
        case device
        case chargingType
        case chargingMode
        case charger
        case initialCheckpointEmpty
        case initialCheckpointInvalid

        var id: String {
            switch self {
            case .existingSession:         return "existing-session"
            case .device:                  return "device"
            case .chargingType:            return "charging-type"
            case .chargingMode:            return "charging-mode"
            case .charger:                 return "charger"
            case .initialCheckpointEmpty:  return "initial-checkpoint-empty"
            case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
            }
        }

        var message: String {
            switch self {
            case .existingSession:          return "Stop or pause the current session before starting another one."
            case .device:                   return "Select the device that is charging."
            case .chargingType:             return "Choose the charging type for this session."
            case .chargingMode:             return "Choose whether the device is on or off for this session."
            case .charger:                  return "Select the wireless charger used in this session."
            case .initialCheckpointEmpty:   return "Enter the initial battery percentage."
            case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
            }
        }
    }

    @EnvironmentObject private var appData: AppData
    @EnvironmentObject private var usbMeter: Meter

    @State private var draftChargingTransportMode: ChargingTransportMode?
    @State private var draftChargingStateMode: ChargingStateMode?
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
    @State private var initialCheckpoint = ""
    @State private var showsMeterTotalsInfo = false
    @State private var activeMode: ActiveMode = .chargeSession
    @State private var draftChargedDeviceID: UUID?
    @State private var draftChargerID: UUID?

    var body: some View {
        Group {
            if let openChargeSession {
                ChargeSessionDetailView(
                    chargedDeviceID: openChargeSession.chargedDeviceID,
                    sessionID: openChargeSession.id,
                    monitoringMeter: usbMeter,
                    presentation: .embedded
                )
            } else {
                ScrollView {
                    VStack(spacing: 14) {
                        statusHeader
                        liveMeterStripView
                        modePicker

                        switch activeMode {
                        case .chargeSession:
                            chargeSessionSetupCard
                        case .standbyPower:
                            standbyPowerCard
                        }
                    }
                    .padding()
                }
            }
        }
        .background(
            LinearGradient(
                colors: [.pink.opacity(0.14), Color.clear],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
        .onAppear {
            syncDraftSelections()
        }
        .onChange(of: selectedChargedDevice?.id) { _ in
            syncDraftSelections()
        }
        .onChange(of: openChargeSession?.id) { _ in
            syncDraftSelections()
        }
    }

    // MARK: - Computed Properties

    private var meterMACAddress: String {
        usbMeter.btSerial.macAddress.description
    }

    private var selectedChargedDevice: ChargedDeviceSummary? {
        if let openChargeSession {
            return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID)
        }

        guard let draftChargedDeviceID else { return nil }
        let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID)
        return chargedDevice?.isCharger == false ? chargedDevice : nil
    }

    private var availableChargedDevices: [ChargedDeviceSummary] {
        appData.deviceSummaries
    }

    private var selectedChargedDeviceID: Binding<UUID?> {
        Binding(
            get: { openChargeSession?.chargedDeviceID ?? draftChargedDeviceID },
            set: { newValue in
                draftChargedDeviceID = newValue
                if newValue == nil {
                    draftChargingTransportMode = nil
                    draftChargingStateMode = nil
                }
            }
        )
    }

    private var selectedCharger: ChargedDeviceSummary? {
        if let openChargeSession,
           let chargerID = openChargeSession.chargerID {
            return appData.chargedDeviceSummary(id: chargerID)
        }

        guard let draftChargerID else { return nil }
        let charger = appData.chargedDeviceSummary(id: draftChargerID)
        return charger?.isCharger == true ? charger : nil
    }

    private var availableChargers: [ChargedDeviceSummary] {
        appData.chargerSummaries
    }

    private var selectedChargerID: Binding<UUID?> {
        Binding(
            get: { openChargeSession?.chargerID ?? draftChargerID },
            set: { newValue in
                draftChargerID = newValue
            }
        )
    }

    private var openChargeSession: ChargeSessionSummary? {
        appData.activeChargeSessionSummary(for: meterMACAddress)
    }

    private var showsMeterTotalsCard: Bool {
        usbMeter.supportsRecordingView
            || usbMeter.supportsDataGroupCommands
            || usbMeter.recordedAH > 0
            || usbMeter.recordedWH > 0
            || usbMeter.recordingDuration > 0
    }

    private var selectedDraftTransportMode: ChargingTransportMode? {
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
    }

    private var selectedDraftChargingStateMode: ChargingStateMode? {
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
    }

    private var initialCheckpointValue: Double? {
        guard initialCheckpointMode == .known else { return nil }
        let normalized = initialCheckpoint
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: ",", with: ".")
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
        return value
    }

    private var hasInitialCheckpointInput: Bool {
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
    }

    private var shouldRequireInitialCheckpoint: Bool {
        initialCheckpointMode == .known
    }

    private var requiresExplicitTransportSelection: Bool {
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
    }

    private var requiresExplicitChargingStateSelection: Bool {
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
    }

    private var startRequirements: [SessionStartRequirement] {
        var requirements: [SessionStartRequirement] = []

        if openChargeSession != nil {
            requirements.append(.existingSession)
        }

        guard let selectedChargedDevice else {
            requirements.append(.device)
            return requirements
        }

        guard let chargingTransportMode = selectedDraftTransportMode else {
            requirements.append(.chargingType)
            return requirements
        }

        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
            requirements.append(.chargingType)
        }

        guard let chargingStateMode = selectedDraftChargingStateMode else {
            requirements.append(.chargingMode)
            return requirements
        }

        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
            requirements.append(.chargingMode)
        }

        if chargingTransportMode == .wireless, selectedCharger == nil {
            requirements.append(.charger)
        }

        if shouldRequireInitialCheckpoint {
            if hasInitialCheckpointInput == false {
                requirements.append(.initialCheckpointEmpty)
            } else if initialCheckpointValue == nil {
                requirements.append(.initialCheckpointInvalid)
            }
        }

        return requirements
    }

    private var canStartSession: Bool {
        startRequirements.isEmpty
    }

    private var headerStatusTitle: String {
        guard let openChargeSession else { return "Idle" }
        return openChargeSession.status.title
    }

    private var headerStatusColor: Color {
        guard let openChargeSession else { return .secondary }
        switch openChargeSession.status {
        case .active:    return .red
        case .paused:    return .orange
        case .completed: return .green
        case .abandoned: return .secondary
        }
    }

    private var showsWirelessChargerSection: Bool {
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
        return transportMode == .wireless
    }

    // MARK: - Status Header

    private var statusHeader: some View {
        HStack {
            Image(systemName: "bolt.fill")
                .foregroundColor(.pink)
            Text("Charging Session")
                .font(.system(.title3, design: .rounded).weight(.bold))
            Spacer()
            Text(headerStatusTitle)
                .font(.caption.weight(.bold))
                .foregroundColor(headerStatusColor)
                .padding(.horizontal, 10)
                .padding(.vertical, 6)
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
        }
        .padding(.horizontal, 18)
        .padding(.vertical, 12)
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
    }

    // MARK: - Mode Picker

    private var modePicker: some View {
        Picker("", selection: $activeMode) {
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
        }
        .pickerStyle(.segmented)
        .labelsHidden()
    }

    // MARK: - Charge Session Setup

    private var chargeSessionSetupCard: some View {
        VStack(alignment: .leading, spacing: 0) {
            // Device
            setupRow(icon: "iphone", iconColor: .blue) {
                Picker(selection: selectedChargedDeviceID) {
                    Text("Choose device").tag(UUID?.none)
                    ForEach(availableChargedDevices) { device in
                        Text(device.name).tag(Optional(device.id))
                    }
                } label: {
                    HStack(spacing: 8) {
                        if let device = selectedChargedDevice {
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
                                .font(.subheadline.weight(.semibold))
                        } else {
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
                                .foregroundColor(.secondary)
                                .font(.subheadline)
                        }
                        Spacer(minLength: 8)
                        Image(systemName: "chevron.up.chevron.down")
                            .font(.caption.weight(.semibold))
                            .foregroundColor(.secondary)
                    }
                }
                .pickerStyle(.menu)
                .disabled(availableChargedDevices.isEmpty)
            }

            // Charging type — only when device supports multiple
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
                Divider().padding(.leading, 46)
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
                    Text("Type")
                        .foregroundColor(.secondary)
                        .font(.subheadline)
                    Spacer()
                    compactSelectionMenu(
                        title: draftChargingTransportMode?.title ?? "Choose",
                        options: device.supportedChargingModes.map { mode in
                            CompactSelectionOption(
                                id: mode.id, title: mode.title,
                                isSelected: draftChargingTransportMode == mode,
                                action: { draftChargingTransportMode = mode }
                            )
                        }
                    )
                }
            }

            // Charging state — only when device supports multiple
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
                Divider().padding(.leading, 46)
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
                    Text("Mode")
                        .foregroundColor(.secondary)
                        .font(.subheadline)
                    Spacer()
                    compactSelectionMenu(
                        title: draftChargingStateMode?.title ?? "Choose",
                        options: device.supportedChargingStateModes.map { mode in
                            CompactSelectionOption(
                                id: mode.id, title: mode.title,
                                isSelected: draftChargingStateMode == mode,
                                action: { draftChargingStateMode = mode }
                            )
                        }
                    )
                }
            }

            // Wireless charger — only when wireless transport
            if showsWirelessChargerSection {
                Divider().padding(.leading, 46)
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
                    Picker(selection: selectedChargerID) {
                        Text("Choose charger").tag(UUID?.none)
                        ForEach(availableChargers) { charger in
                            Text(charger.name).tag(Optional(charger.id))
                        }
                    } label: {
                        HStack(spacing: 8) {
                            if let charger = selectedCharger {
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
                                    .font(.subheadline.weight(.semibold))
                            } else {
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
                                    .foregroundColor(.secondary)
                                    .font(.subheadline)
                            }
                            Spacer(minLength: 8)
                            Image(systemName: "chevron.up.chevron.down")
                                .font(.caption.weight(.semibold))
                                .foregroundColor(.secondary)
                        }
                    }
                    .pickerStyle(.menu)
                    .disabled(availableChargers.isEmpty)
                }
            }

            // Battery checkpoint
            Divider().padding(.leading, 46)
            setupRow(icon: "battery.75percent", iconColor: .green) {
                if initialCheckpointMode == .known {
                    Button { adjustInitialCheckpoint(by: -1) } label: {
                        Image(systemName: "minus.circle").font(.title3)
                    }
                    .buttonStyle(.plain)

                    TextField("—", text: $initialCheckpoint)
                        .keyboardType(.decimalPad)
                        .textFieldStyle(.roundedBorder)
                        .frame(width: 52)
                        .multilineTextAlignment(.center)

                    Text("%")
                        .font(.subheadline)
                        .foregroundColor(.secondary)

                    Button { adjustInitialCheckpoint(by: 1) } label: {
                        Image(systemName: "plus.circle").font(.title3)
                    }
                    .buttonStyle(.plain)
                } else {
                    Text(initialCheckpointMode == .flat
                         ? "Flat (device off / discharged)"
                         : "Unknown")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                Spacer()
                compactSelectionMenu(
                    title: initialCheckpointMode.title,
                    options: InitialCheckpointMode.allCases.map { mode in
                        CompactSelectionOption(
                            id: mode.id, title: mode.title,
                            isSelected: initialCheckpointMode == mode,
                            action: { initialCheckpointMode = mode }
                        )
                    }
                )
            }

            // Requirement errors
            if startRequirements.isEmpty == false {
                Divider()
                VStack(alignment: .leading, spacing: 6) {
                    ForEach(startRequirements) { requirement in
                        Label(requirement.message, systemImage: "exclamationmark.circle")
                            .font(.caption)
                            .foregroundColor(.orange)
                    }
                }
                .padding(.horizontal, 14)
                .padding(.vertical, 10)
            }

            // Start button
            Divider()
            Button("Start Session") {
                startSession()
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 11)
            .font(.subheadline.weight(.semibold))
            .foregroundColor(canStartSession ? .green : .secondary)
            .buttonStyle(.plain)
            .disabled(!canStartSession)
        }
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
    }

    // MARK: - Standby Power Card

    private var standbyPowerCard: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 10) {
                Image(systemName: "powersleep")
                    .foregroundColor(.orange)
                    .font(.title3)
                VStack(alignment: .leading, spacing: 2) {
                    Text("Charger Standby Power")
                        .font(.subheadline.weight(.semibold))
                    Text("Measure idle draw with no device connected.")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }

            NavigationLink(
                destination: ChargerStandbyPowerWizardView(
                    preferredMeterMACAddress: meterMACAddress
                )
            ) {
                HStack {
                    Image(systemName: "plus.circle.fill")
                        .foregroundColor(.orange)
                    Text("New Measurement")
                        .font(.subheadline.weight(.semibold))
                    Spacer()
                    Image(systemName: "chevron.right")
                        .font(.caption.weight(.semibold))
                        .foregroundColor(.secondary)
                }
                .padding(.vertical, 10)
                .padding(.horizontal, 14)
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
            }
            .buttonStyle(.plain)
        }
        .padding(18)
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
    }

    // MARK: - Live Meter Strip (idle state)

    private var liveMeterStripView: some View {
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
        return LazyVGrid(columns: columns, spacing: 8) {
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
        }
    }

    private func metricCell(label: String, value: String, tint: Color) -> some View {
        VStack(alignment: .leading, spacing: 3) {
            Text(label)
                .font(.caption2)
                .foregroundColor(.secondary)
            Text(value)
                .font(.subheadline.weight(.semibold))
                .lineLimit(1)
                .minimumScaleFactor(0.7)
                .monospacedDigit()
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 12)
        .padding(.vertical, 10)
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
    }

    private var meterTotalsCard: some View {
        return VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 8) {
                Text("Meter Recorder")
                    .font(.headline)

                Spacer(minLength: 0)

                Button {
                    showsMeterTotalsInfo.toggle()
                } label: {
                    Image(systemName: "info.circle")
                        .font(.body.weight(.semibold))
                        .foregroundColor(.secondary)
                }
                .buttonStyle(.plain)
                .accessibilityLabel("Meter recorder info")
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
                    VStack(alignment: .leading, spacing: 10) {
                        Text("Meter Recorder")
                            .font(.headline)
                        Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
                            .font(.body)
                            .fixedSize(horizontal: false, vertical: true)
                    }
                    .padding(16)
                    .frame(width: 280, alignment: .leading)
                }
            }

            ChargeRecordMetricsTableView(
                labels: ["Energy", "Duration", "Meter Threshold"],
                values: [
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
                    usbMeter.recordingDurationDescription,
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
                ]
            )

            if let recordingBootedAt = usbMeter.recordingBootedAt {
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding(18)
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
    }

    // MARK: - Helpers

    private func setupRow<Content: View>(
        icon: String,
        iconColor: Color = .secondary,
        @ViewBuilder content: () -> Content
    ) -> some View {
        HStack(spacing: 10) {
            Image(systemName: icon)
                .foregroundColor(iconColor)
                .font(.body.weight(.medium))
                .frame(width: 22, alignment: .center)
            content()
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 11)
    }

    private func startSession() {
        guard let selectedChargedDevice,
              let chargingTransportMode = selectedDraftTransportMode,
              let chargingStateMode = selectedDraftChargingStateMode else {
            return
        }

        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
        let didStart = appData.startChargeSession(
            for: usbMeter,
            chargedDeviceID: selectedChargedDevice.id,
            chargerID: chargerID,
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode,
            autoStopEnabled: false,
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
            startsFromFlatBattery: initialCheckpointMode == .flat
        )

        if didStart {
            initialCheckpoint = ""
            initialCheckpointMode = .known
        }
    }

    private func adjustInitialCheckpoint(by delta: Double) {
        guard initialCheckpointMode == .known else { return }
        let currentValue = initialCheckpointValue ?? 0
        let nextValue = min(max(currentValue + delta, 0), 100)
        initialCheckpoint = nextValue.format(decimalDigits: 0)
    }

    private func syncDraftSelections() {
        guard let selectedChargedDevice else {
            draftChargingTransportMode = nil
            draftChargingStateMode = nil
            return
        }

        if let openChargeSession {
            draftChargingTransportMode = openChargeSession.chargingTransportMode
            draftChargingStateMode = openChargeSession.chargingStateMode
            return
        }

        if let draftChargingTransportMode,
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
            self.draftChargingTransportMode = nil
        }

        if let draftChargingStateMode,
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
            self.draftChargingStateMode = nil
        }

        if selectedChargedDevice.supportedChargingModes.count == 1 {
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
        }

        if let draftChargingTransportMode {
            draftChargingStateMode = draftChargingStateMode
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
        }
    }

    private struct CompactSelectionOption: Identifiable {
        let id: String
        let title: String
        let isSelected: Bool
        let action: () -> Void
    }

    private func compactSelectionMenu(
        title: String,
        options: [CompactSelectionOption]
    ) -> some View {
        Menu {
            ForEach(options) { option in
                Button {
                    option.action()
                } label: {
                    if option.isSelected {
                        Label(option.title, systemImage: "checkmark")
                    } else {
                        Text(option.title)
                    }
                }
            }
        } label: {
            HStack(spacing: 8) {
                Text(title)
                    .foregroundColor(.primary)
                Spacer()
                Image(systemName: "chevron.up.chevron.down")
                    .font(.caption.weight(.semibold))
                    .foregroundColor(.secondary)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 9)
            .frame(width: 160, alignment: .leading)
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
        }
        .buttonStyle(.plain)
    }
}