USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
1 contributor
316 lines | 10.911kb
//
//  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 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 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 {
        guard let percent = normalizedBatteryPercent else {
            return false
        }
        return percent >= 0 && percent <= 100
    }

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

            compactEditorRow
        }
    }

    private var compactEditorRow: some View {
        HStack(spacing: 8) {
            TextField("Battery %", text: $batteryPercent)
                .keyboardType(.decimalPad)
                .textFieldStyle(.roundedBorder)
                .frame(width: 104)
                .onSubmit(saveCheckpoint)

            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() {
        guard let percent = normalizedBatteryPercent else {
            return
        }

        if appData.addBatteryCheckpoint(
            percent: percent,
            for: sessionID,
            measuredEnergyWh: effectiveEnergyWhOverride
        ) {
            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())
    }
}