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