1 contributor
//
// 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 measuredChargeAhOverride: 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?,
measuredChargeAhOverride: Double?,
onCancel: (() -> Void)?,
onSaved: (() -> Void)?,
showsHeader: Bool = true
) {
self.sessionID = sessionID
self.message = message
self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
self.measuredChargeAhOverride = measuredChargeAhOverride
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,
measuredChargeAh: measuredChargeAhOverride
) {
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 measuredChargeAhOverride: 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,
measuredChargeAhOverride: measuredChargeAhOverride,
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,
measuredChargeAhOverride: 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())
}
}