USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
1 contributor
419 lines | 14.703kb
//
//  BatteryCheckpointEditorSheetView.swift
//  USB Meter
//
//  Created by Codex on 10/04/2026.
//

import SwiftUI

struct BatteryCheckpointEditorContentView: View {
    @EnvironmentObject private var appData: AppData

    let sessionID: UUID
    let message: String
    let effectiveEnergyWhOverride: Double?
    let onCancel: (() -> Void)?
    let onSaved: (() -> Void)?
    let showsHeader: Bool

    @State private var batteryPercent = ""
    @State private var barsValue: Int = 0
    @State private var subject: CheckpointSubject = .chargedDevice
    @State private var showsWarningPopover = false

    init(
        sessionID: UUID,
        message: String,
        effectiveEnergyWhOverride: Double?,
        onCancel: (() -> Void)?,
        onSaved: (() -> Void)?,
        showsHeader: Bool = true
    ) {
        self.sessionID = sessionID
        self.message = message
        self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
        self.onCancel = onCancel
        self.onSaved = onSaved
        self.showsHeader = showsHeader
    }

    private var sourcePowerbank: PowerbankSummary? {
        guard let session = appData.chargeSessionSummary(id: sessionID),
              let powerbankID = session.sourcePowerbankID else {
            return nil
        }
        return appData.powerbankSummaries.first { $0.id == powerbankID }
    }

    private var chargedPowerbank: PowerbankSummary? {
        guard let session = appData.chargeSessionSummary(id: sessionID),
              let powerbankID = session.chargedPowerbankID else {
            return nil
        }
        return appData.powerbankSummaries.first { $0.id == powerbankID }
    }

    private var allowsSubjectToggle: Bool {
        chargedPowerbank == nil && sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true
    }

    private var activeReporting: BatteryLevelReporting {
        if let chargedPowerbank {
            return chargedPowerbank.batteryLevelReporting
        }
        if subject == .powerbank, let sourcePowerbank {
            return sourcePowerbank.batteryLevelReporting
        }
        return .percent
    }

    private var activeBarsCount: Int {
        max(1, (chargedPowerbank ?? sourcePowerbank)?.batteryBarsCount ?? 1)
    }

    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
        guard let percent = normalizedBatteryPercent else {
            return nil
        }
        return appData.batteryCheckpointPlausibilityWarning(
            percent: percent,
            for: sessionID,
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
        )
    }

    private var normalizedBatteryPercent: Double? {
        let normalized = batteryPercent
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: ",", with: ".")
        return Double(normalized)
    }

    private var canSave: Bool {
        switch activeReporting {
        case .percent:
            guard let percent = normalizedBatteryPercent else { return false }
            return percent >= 0 && percent <= 100
        case .bars:
            return barsValue >= 0 && barsValue <= activeBarsCount
        case .fullOnly:
            // Always savable — the only emitted value is the 100% anchor.
            return true
        case .none:
            return false
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            if showsHeader {
                HStack(spacing: 8) {
                    Text("Checkpoint")
                    Spacer(minLength: 0)
                    ContextInfoButton(
                        title: "Checkpoint",
                        message: message
                    )
                }
            }

            if allowsSubjectToggle {
                Picker("Subject", selection: $subject) {
                    Text("Device").tag(CheckpointSubject.chargedDevice)
                    Text("Powerbank").tag(CheckpointSubject.powerbank)
                }
                .pickerStyle(.segmented)
            }

            compactEditorRow
        }
        .onAppear {
            if chargedPowerbank != nil {
                subject = .powerbank
            }
        }
    }

    @ViewBuilder
    private var subjectInput: some View {
        switch activeReporting {
        case .percent:
            TextField("Battery %", text: $batteryPercent)
                .keyboardType(.decimalPad)
                .textFieldStyle(.roundedBorder)
                .frame(width: 104)
                .onSubmit(saveCheckpoint)
        case .bars:
            HStack(spacing: 6) {
                Stepper(value: $barsValue, in: 0...activeBarsCount) {
                    Text("\(barsValue) / \(activeBarsCount)")
                        .font(.subheadline)
                }
                .frame(width: 160)
            }
        case .fullOnly:
            // Single-LED powerbanks only signal completion. The only meaningful checkpoint
            // is "full" — anything else would be a guess. Tapping the action saves at 100%.
            Label("Full LED is on", systemImage: "lightbulb.fill")
                .font(.caption)
                .foregroundColor(.secondary)
                .frame(width: 220, alignment: .leading)
        case .none:
            Text("Battery level reporting disabled")
                .font(.caption)
                .foregroundColor(.secondary)
                .frame(width: 220, alignment: .leading)
        }
    }

    private var compactEditorRow: some View {
        HStack(spacing: 8) {
            subjectInput

            if let plausibilityWarning {
                Button {
                    showsWarningPopover.toggle()
                } label: {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .font(.body.weight(.semibold))
                        .foregroundColor(.orange)
                }
                .buttonStyle(.plain)
                .accessibilityLabel(plausibilityWarning.title)
                .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
                    VStack(alignment: .leading, spacing: 10) {
                        Text(plausibilityWarning.title)
                            .font(.headline)
                        Text(plausibilityWarning.message)
                            .font(.body)
                            .fixedSize(horizontal: false, vertical: true)
                    }
                    .padding(16)
                    .frame(width: 320, alignment: .leading)
                }
            }

            if let onCancel {
                inlineActionButton(
                    systemName: "xmark",
                    tint: .secondary,
                    fillOpacity: 0.12,
                    strokeOpacity: 0.18,
                    isEnabled: true,
                    action: onCancel
                )
            }

            inlineActionButton(
                systemName: "checkmark",
                tint: .green,
                fillOpacity: 0.16,
                strokeOpacity: 0.22,
                isEnabled: canSave,
                action: saveCheckpoint
            )
        }
    }

    private func inlineActionButton(
        systemName: String,
        tint: Color,
        fillOpacity: Double,
        strokeOpacity: Double,
        isEnabled: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Image(systemName: systemName)
                .font(.caption.weight(.semibold))
                .frame(width: 30, height: 30)
                .contentShape(Rectangle())
        }
        .meterCard(
            tint: tint,
            fillOpacity: fillOpacity,
            strokeOpacity: strokeOpacity,
            cornerRadius: 10
        )
        .buttonStyle(.plain)
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.6)
    }

    private func saveCheckpoint() {
        let resolvedPercent: Double
        let resolvedBars: Int
        switch activeReporting {
        case .percent:
            guard let percent = normalizedBatteryPercent else { return }
            resolvedPercent = percent
            resolvedBars = 0
        case .bars:
            resolvedBars = barsValue
            resolvedPercent = activeBarsCount > 0
                ? min(100, max(0, Double(barsValue) / Double(activeBarsCount) * 100))
                : 0
        case .fullOnly:
            // Single-LED powerbanks: the only meaningful anchor is "full".
            resolvedPercent = 100
            resolvedBars = 0
        case .none:
            return
        }

        if appData.addBatteryCheckpoint(
            percent: resolvedPercent,
            for: sessionID,
            measuredEnergyWh: effectiveEnergyWhOverride,
            subject: subject,
            barsValue: resolvedBars
        ) {
            onSaved?()
        }
    }
}

struct BatteryCheckpointSectionView: View {
    let sessionID: UUID
    let checkpoints: [ChargeCheckpointSummary]
    let message: String
    let canAddCheckpoint: Bool
    let canDeleteCheckpoint: Bool
    let requirementMessage: String?
    let effectiveEnergyWhOverride: Double?
    let onDelete: (ChargeCheckpointSummary) -> Void

    @State private var showsInlineCheckpointEditor = false

    private var displayedCheckpoints: [ChargeCheckpointSummary] {
        Array(checkpoints.suffix(6).reversed())
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(alignment: .center, spacing: 8) {
                Text("Battery Checkpoints")
                    .font(.subheadline.weight(.semibold))

                ContextInfoButton(
                    title: "Battery Checkpoints",
                    message: message
                )

                Spacer(minLength: 12)

                if canAddCheckpoint {
                    if showsInlineCheckpointEditor {
                        BatteryCheckpointEditorContentView(
                            sessionID: sessionID,
                            message: message,
                            effectiveEnergyWhOverride: effectiveEnergyWhOverride,
                            onCancel: { showsInlineCheckpointEditor = false },
                            onSaved: { showsInlineCheckpointEditor = false },
                            showsHeader: false
                        )
                    } else {
                        Button {
                            showsInlineCheckpointEditor = true
                        } label: {
                            Image(systemName: "plus")
                                .font(.caption.weight(.semibold))
                                .frame(width: 30, height: 30)
                                .contentShape(Rectangle())
                        }
                        .meterCard(
                            tint: .green,
                            fillOpacity: 0.12,
                            strokeOpacity: 0.18,
                            cornerRadius: 10
                        )
                        .buttonStyle(.plain)
                        .help("Add checkpoint")
                    }
                }
            }

            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
                HStack {
                    Text(checkpoint.timestamp.format())
                        .font(.caption2)
                        .foregroundColor(.secondary)
                    Text(checkpoint.flag.title)
                        .font(.caption2.weight(.semibold))
                        .foregroundColor(.secondary)
                    Spacer()
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
                        .font(.caption.weight(.semibold))
                    Text("•")
                        .foregroundColor(.secondary)
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                    if canDeleteCheckpoint {
                        Button {
                            onDelete(checkpoint)
                        } label: {
                            Image(systemName: "trash")
                                .font(.caption.weight(.semibold))
                                .foregroundColor(.red)
                        }
                        .buttonStyle(.plain)
                        .help("Delete checkpoint")
                    }
                }
            }

            if !canAddCheckpoint, let requirementMessage {
                Text(requirementMessage)
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
        }
    }
}

struct BatteryCheckpointEditorSheetView: View {
    @EnvironmentObject private var appData: AppData
    @EnvironmentObject private var meter: Meter
    @Environment(\.dismiss) private var dismiss

    private var activeSession: ChargeSessionSummary? {
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
    }

    var body: some View {
        NavigationView {
            Group {
                if let activeSession {
                    Form {
                        BatteryCheckpointEditorContentView(
                            sessionID: activeSession.id,
                            message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
                            effectiveEnergyWhOverride: nil,
                            onCancel: { dismiss() },
                            onSaved: { dismiss() },
                            showsHeader: true
                        )
                    }
                } else {
                    VStack(spacing: 12) {
                        Image(systemName: "bolt.slash")
                            .font(.title2)
                            .foregroundColor(.secondary)
                        Text("No Active Session")
                            .font(.headline)
                        Text("Start a charging session before adding a battery checkpoint.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                            .multilineTextAlignment(.center)
                    }
                    .padding(24)
                }
            }
            .navigationTitle("Battery Checkpoint")
            .navigationBarTitleDisplayMode(.inline)
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}