// // 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()) } }