1 contributor
//
// ChargeSessionDetailView.swift
// USB Meter
//
// Created by Codex on 22/04/2026.
//
import SwiftUI
enum ChargeSessionDetailPresentation {
case navigation
case embedded
}
struct ChargeSessionDetailView: View {
private enum FinalCheckpoint: Hashable {
case full
case skip
case custom
var label: String {
switch self {
case .full: return "Full"
case .skip: return "Skip"
case .custom: return "Other %"
}
}
var icon: String {
switch self {
case .full: return "battery.100percent"
case .skip: return "minus.circle"
case .custom: return "pencil"
}
}
}
private struct BatteryPercentCandidate {
let timestamp: Date
let percent: Double
let isCheckpoint: Bool
}
@EnvironmentObject private var appData: AppData
let chargedDeviceID: UUID
let sessionID: UUID
let monitoringMeter: Meter?
let presentation: ChargeSessionDetailPresentation
@State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
@State private var pendingSessionDeletion: ChargeSessionSummary?
@State private var pendingSessionStopRequest: ChargeSessionStopRequest?
@State private var pendingTrimCommitSession: ChargeSessionSummary?
@State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
@State private var trimBannerDismissedForSessionID: UUID?
@State private var showingInlineTargetEditor = false
@State private var draftTargetText = ""
@State private var showingStopConfirm = false
@State private var finalCheckpointMode: FinalCheckpoint = .skip
@State private var isBatteryCardExpanded = false
@State private var finalCheckpointText = ""
@State private var stopFailureMessage: String?
init(
chargedDeviceID: UUID,
sessionID: UUID,
monitoringMeter: Meter? = nil,
presentation: ChargeSessionDetailPresentation = .navigation
) {
self.chargedDeviceID = chargedDeviceID
self.sessionID = sessionID
self.monitoringMeter = monitoringMeter
self.presentation = presentation
}
private var chargedDevice: ChargedDeviceSummary? {
appData.chargedDeviceSummary(id: chargedDeviceID)
}
private var session: ChargeSessionSummary? {
chargedDevice?.sessions.first(where: { $0.id == sessionID })
}
private var liveMonitoringMeter: Meter? {
guard let session,
session.status.isOpen,
let meterMACAddress = session.meterMACAddress else {
return nil
}
if let monitoringMeter,
monitoringMeter.btSerial.macAddress.description == meterMACAddress {
return monitoringMeter
}
return appData.meters.values.first {
$0.btSerial.macAddress.description == meterMACAddress
}
}
private var hasMonitoringControls: Bool {
session?.status.isOpen == true && liveMonitoringMeter != nil
}
private var shouldShowTrimBanner: Bool {
guard hasMonitoringControls,
let session,
session.isTrimmed == false,
trimBannerDismissedForSessionID != session.id,
let detectedTrimWindow else {
return false
}
return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold
}
var body: some View {
Group {
if let chargedDevice, let session {
content(chargedDevice: chargedDevice, session: session)
} else {
unavailableState
}
}
.sheet(item: $pendingSessionStopRequest) { request in
ChargeSessionCompletionSheetView(
sessionID: request.sessionID,
title: request.title,
confirmTitle: request.confirmTitle,
explanation: request.explanation,
monitoringMeter: liveMonitoringMeter,
appliesTrim: request.appliesTrim,
trimStart: request.trimStart,
trimEnd: request.trimEnd
)
.environmentObject(appData)
}
.alert(item: $pendingCheckpointDeletion) { checkpoint in
Alert(
title: Text("Delete Battery Checkpoint"),
message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
primaryButton: .destructive(Text("Delete")) {
_ = appData.deleteBatteryCheckpoint(
checkpointID: checkpoint.id,
for: checkpoint.sessionID
)
},
secondaryButton: .cancel()
)
}
.alert(item: $pendingSessionDeletion) { session in
Alert(
title: Text("Delete Session?"),
message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
primaryButton: .destructive(Text("Delete")) {
_ = appData.deleteChargeSession(sessionID: session.id)
},
secondaryButton: .cancel()
)
}
.alert(item: $pendingTrimCommitSession) { session in
Alert(
title: Text("Save Trim Permanently?"),
message: Text("Samples and checkpoints outside \(session.effectiveTrimStart.format()) - \(session.effectiveTrimEnd.format()) will be deleted. Reset Trim will no longer restore them."),
primaryButton: .destructive(Text("Save Trim")) {
_ = appData.commitSessionTrim(sessionID: session.id)
},
secondaryButton: .cancel()
)
}
.onAppear {
syncMonitoringRestore()
runTrimDetection()
}
.onChange(of: session?.id) { _ in
pendingSessionStopRequest = nil
pendingTrimCommitSession = nil
detectedTrimWindow = nil
trimBannerDismissedForSessionID = nil
showingInlineTargetEditor = false
draftTargetText = ""
showingStopConfirm = false
finalCheckpointMode = .skip
isBatteryCardExpanded = false
finalCheckpointText = ""
stopFailureMessage = nil
syncMonitoringRestore()
runTrimDetection()
}
.onChange(of: session?.aggregatedSamples.count) { _ in
syncMonitoringRestore()
runTrimDetection()
}
.onChange(of: session?.checkpoints.count) { _ in
syncMonitoringRestore()
}
.onChange(of: finalCheckpointMode) { _ in
stopFailureMessage = nil
}
.onChange(of: finalCheckpointText) { _ in
stopFailureMessage = nil
}
}
private func content(
chargedDevice: ChargedDeviceSummary,
session: ChargeSessionSummary
) -> some View {
ScrollView {
VStack(spacing: 16) {
if hasMonitoringControls {
monitoringSessionCard(session, chargedDevice: chargedDevice)
if shouldShowTrimBanner {
trimDetectionBanner(session)
}
if shouldShowSessionChart(session) {
chartCard(session, chargedDevice: chargedDevice)
}
} else {
overviewCard(session, chargedDevice: chargedDevice)
batteryCard(session, chargedDevice: chargedDevice)
if shouldShowSessionChart(session) {
chartCard(session, chargedDevice: chargedDevice)
}
if session.status.isOpen {
followerNoticeCard(session)
}
}
}
.padding(presentation == .embedded ? 16 : 20)
}
.background(
LinearGradient(
colors: [statusTint(for: session).opacity(0.14), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
if session.status.isOpen == false {
Button(role: .destructive) {
pendingSessionDeletion = session
} label: {
Image(systemName: "trash")
}
.help("Delete session")
}
}
}
}
private var unavailableState: some View {
VStack(spacing: 12) {
Image(systemName: "bolt.slash")
.font(.title2)
.foregroundColor(.secondary)
Text("This session is no longer available.")
.font(.headline)
Text("It may have been deleted or synced from another device.")
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
.navigationTitle("Session")
.navigationBarTitleDisplayMode(.inline)
}
private func monitoringSessionCard(
_ session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
let displayedEnergyWh = displayedSessionEnergyWh(for: session)
let batteryPrediction = chargedDevice.batteryLevelPrediction(
for: session,
effectiveEnergyWhOverride: displayedEnergyWh
)
return VStack(alignment: .leading, spacing: 14) {
HStack {
ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
.font(.headline)
Spacer()
Text(session.status.title)
.font(.caption.weight(.bold))
.foregroundColor(monitoringStatusColor(for: session))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
}
if let batteryPrediction {
batteryGaugeSection(
prediction: batteryPrediction,
session: session,
displayedEnergyWh: displayedEnergyWh
)
}
sessionMetricsGrid(
session: session,
chargedDevice: chargedDevice,
displayedEnergyWh: displayedEnergyWh,
hasPrediction: batteryPrediction != nil
)
if session.stopThresholdAmps > 0 {
Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
.font(.caption)
.foregroundColor(.secondary)
}
if let sessionWarning = sessionWarning(for: session) {
Label(sessionWarning, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundColor(.orange)
}
if session.isPaused {
Label(
"Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
systemImage: "pause.circle"
)
.font(.caption)
.foregroundColor(.secondary)
}
if session.requiresCompletionConfirmation && !showingStopConfirm {
completionConfirmationCard(session)
}
BatteryCheckpointSectionView(
sessionID: session.id,
checkpoints: session.checkpoints,
message: "Checkpoints are used for capacity estimation and the typical charge curve.",
canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
canDeleteCheckpoint: true,
requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
effectiveEnergyWhOverride: displayedEnergyWh,
onDelete: { checkpoint in
pendingCheckpointDeletion = checkpoint
}
)
targetSectionView(
session: session,
predictedPercent: batteryPrediction?.predictedPercent
)
if showingStopConfirm {
stopConfirmPanel(
session: session,
displayedEnergyWh: displayedEnergyWh
)
} else {
monitoringActionRow(session)
}
}
.padding(18)
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
}
private func overviewCard(
_ session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
MeterInfoCardView(
title: session.status.isOpen ? "Open Session" : "Overview",
tint: statusTint(for: session),
isCollapsible: true,
initiallyExpanded: false,
trailingActions: {
HStack(spacing: 4) {
Text(session.startedAt, style: .time)
.font(.caption2)
.foregroundColor(.secondary)
.monospacedDigit()
Text("·")
.font(.caption2)
.foregroundColor(.secondary)
Text(sessionDurationText(session))
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.monospacedDigit()
}
}
) {
VStack(alignment: .leading, spacing: 10) {
MeterInfoRowView(label: "Device", value: chargedDevice.name)
Divider()
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Started", value: session.startedAt.format())
if let endedAt = session.endedAt {
overviewStatCell(label: "Ended", value: endedAt.format())
}
}
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Duration", value: sessionDurationText(session))
overviewStatCell(label: "Status", value: session.status.title)
}
Divider()
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
}
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Source", value: session.sourceMode.title)
overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
}
if session.isTrimmed {
Divider()
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
}
}
let meterLabel: String? = session.meterName ?? session.meterMACAddress
if meterLabel != nil || session.meterModel != nil {
Divider()
HStack(alignment: .top, spacing: 12) {
if let label = meterLabel {
overviewStatCell(label: "Meter", value: label)
}
if let model = session.meterModel {
overviewStatCell(label: "Meter Model", value: model)
}
}
}
if session.minimumObservedCurrentAmps != nil
|| session.maximumObservedCurrentAmps != nil
|| session.maximumObservedPowerWatts != nil
|| session.maximumObservedVoltageVolts != nil
|| (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
|| session.completionCurrentAmps != nil
|| session.selectedDataGroup != nil {
Divider()
if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
HStack(alignment: .top, spacing: 12) {
if let v = session.minimumObservedCurrentAmps {
overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
}
if let v = session.maximumObservedCurrentAmps {
overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
}
}
}
if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
HStack(alignment: .top, spacing: 12) {
if let v = session.maximumObservedPowerWatts {
overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
}
if let v = session.maximumObservedVoltageVolts {
overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
}
}
}
if session.completionCurrentAmps != nil
|| (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
HStack(alignment: .top, spacing: 12) {
if let v = session.completionCurrentAmps {
overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
}
if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
}
}
}
if let dg = session.selectedDataGroup {
MeterInfoRowView(label: "Data Group", value: "\(dg)")
}
}
}
}
}
private func overviewStatCell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(value)
.font(.footnote.weight(.medium))
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func batteryCard(
_ session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
let displayedEnergyWh = displayedSessionEnergyWh(for: session)
let batteryPrediction = chargedDevice.batteryLevelPrediction(
for: session,
effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
)
let startPercent = session.startBatteryPercent
let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
let showsPreview = startPercent != nil && endPercent != nil
return VStack(alignment: .leading, spacing: 0) {
// Header — always visible, tappable
HStack(spacing: 8) {
Text("Battery")
.font(.headline)
Spacer(minLength: 0)
Image(systemName: "chevron.up")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
.animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.25)) {
isBatteryCardExpanded.toggle()
}
}
// Preview bar — always visible when there is enough data
if showsPreview, let start = startPercent, let end = endPercent {
batteryPreviewBar(
startPercent: start,
endPercent: end,
checkpoints: session.checkpoints,
isEstimatedEnd: isEstimatedEnd
)
.padding(.top, 10)
}
// Collapsible detail
if isBatteryCardExpanded {
VStack(alignment: .leading, spacing: 10) {
// Energy
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
if let capacityEstimateWh = session.capacityEstimateWh {
overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
}
}
if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
}
if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
}
if let chargerID = session.chargerID,
let charger = appData.chargedDeviceSummary(id: chargerID) {
MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
}
if let wirelessSessionHint = wirelessSessionHint(for: session) {
Text(wirelessSessionHint)
.font(.caption2)
.foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
}
if let sessionWarning = sessionWarning(for: session) {
Label(sessionWarning, systemImage: "exclamationmark.triangle")
.font(.caption2)
.foregroundColor(.orange)
}
// Battery percentages
if startPercent != nil || session.endBatteryPercent != nil {
Divider()
HStack(alignment: .top, spacing: 12) {
if let v = startPercent {
overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
}
if let v = session.endBatteryPercent {
overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
}
}
if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
HStack(alignment: .top, spacing: 12) {
if let v = session.batteryDeltaPercent {
overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
}
if let v = session.targetBatteryPercent {
overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
}
}
}
if let batteryPrediction {
HStack(alignment: .top, spacing: 12) {
overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
}
Text(
"Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
)
.font(.caption2)
.foregroundColor(.secondary)
}
}
// Checkpoints
Divider()
BatteryCheckpointSectionView(
sessionID: session.id,
checkpoints: session.checkpoints,
message: session.status.isOpen
? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
onDelete: { checkpoint in
pendingCheckpointDeletion = checkpoint
}
)
}
.padding(.top, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
}
private func batteryPreviewBar(
startPercent: Double,
endPercent: Double,
checkpoints: [ChargeCheckpointSummary],
isEstimatedEnd: Bool
) -> some View {
let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
let endFrac = CGFloat(max(0, min(endPercent, 100)) / 100)
let color = batteryColor(for: endPercent)
return HStack(spacing: 6) {
Text("\(Int(startPercent.rounded()))%")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
.monospacedDigit()
.frame(minWidth: 26, alignment: .trailing)
GeometryReader { geo in
let w = geo.size.width
ZStack(alignment: .leading) {
Capsule()
.fill(Color.primary.opacity(0.10))
Rectangle()
.fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
.frame(width: max(w * (endFrac - startFrac), 3))
.offset(x: w * startFrac)
ForEach(checkpoints, id: \.id) { cp in
let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
let isFinal = cp.flag == .final
Rectangle()
.fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
.frame(width: isFinal ? 2.0 : 1.5, height: 10)
.offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
}
}
.clipShape(Capsule())
}
.frame(height: 8)
HStack(spacing: 1) {
if isEstimatedEnd {
Text("~")
.font(.caption2)
.foregroundColor(.secondary)
}
Text("\(Int(endPercent.rounded()))%")
.font(.caption2.weight(.semibold))
.foregroundColor(isEstimatedEnd ? .secondary : color)
.monospacedDigit()
}
.frame(minWidth: 32, alignment: .leading)
}
}
private func batteryGaugeSection(
prediction: BatteryLevelPrediction,
session: ChargeSessionSummary,
displayedEnergyWh: Double
) -> some View {
let percent = prediction.predictedPercent
let color = batteryColor(for: percent)
let duration = displayedSessionDuration(for: session)
let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
? displayedEnergyWh / duration
: nil
let etaToFull = etaText(
rateWhPerSec: rateWhPerSec,
remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
isRelevant: percent < 98
)
let etaToTarget = etaToTargetText(
session: session,
prediction: prediction,
displayedEnergyWh: displayedEnergyWh,
rateWhPerSec: rateWhPerSec
)
return VStack(spacing: 10) {
HStack(alignment: .lastTextBaseline, spacing: 8) {
HStack(alignment: .lastTextBaseline, spacing: 3) {
Text("\(Int(percent.rounded()))")
.font(.system(size: 52, weight: .bold, design: .rounded))
.foregroundColor(color)
.monospacedDigit()
Text("%")
.font(.title2.weight(.semibold))
.foregroundColor(color.opacity(0.8))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
.font(.callout.weight(.bold))
.foregroundColor(.orange)
.monospacedDigit()
Text("est. capacity")
.font(.caption2)
.foregroundColor(.secondary)
}
}
batteryProgressBar(
percent: percent,
startPercent: session.startBatteryPercent,
targetPercent: session.targetBatteryPercent
)
HStack(spacing: 14) {
if let etaToFull {
etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
}
if let etaToTarget, let target = session.targetBatteryPercent {
etaPill(
icon: "bell.badge.fill",
tint: .indigo,
value: etaToTarget,
label: "to \(Int(target.rounded()))%"
)
}
Spacer()
Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.trailing)
}
}
.padding(14)
.meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
}
private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundColor(tint)
Text(value)
.font(.caption.weight(.bold))
}
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
}
}
private func batteryProgressBar(
percent: Double,
startPercent: Double?,
targetPercent: Double?
) -> some View {
let color = batteryColor(for: percent)
return GeometryReader { geo in
let width = geo.size.width
ZStack(alignment: .leading) {
Capsule()
.fill(Color.primary.opacity(0.10))
Rectangle()
.fill(
LinearGradient(
colors: [color.opacity(0.6), color],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(width * CGFloat(percent / 100), 4))
.animation(.easeInOut(duration: 0.4), value: percent)
if let start = startPercent, start > 2, start < 98 {
Rectangle()
.fill(Color.white.opacity(0.55))
.frame(width: 2, height: 20)
.offset(x: width * CGFloat(start / 100) - 1)
}
if let target = targetPercent {
Rectangle()
.fill(Color.indigo.opacity(0.9))
.frame(width: 2.5, height: 20)
.offset(x: width * CGFloat(target / 100) - 1.25)
}
}
.clipShape(Capsule())
}
.frame(height: 20)
}
private func sessionMetricsGrid(
session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary,
displayedEnergyWh: Double,
hasPrediction: Bool
) -> some View {
let capacityFallback: Double? = hasPrediction ? nil : (
session.capacityEstimateWh
?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
?? chargedDevice.estimatedBatteryCapacityWh
)
let columns = [GridItem(.flexible()), GridItem(.flexible())]
return LazyVGrid(columns: columns, spacing: 8) {
metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
}
if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
}
metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
if let capacityFallback {
metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
}
}
}
private func metricCell(label: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
}
private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
.font(.subheadline.weight(.semibold))
if let contradictionPercent = session.completionContradictionPercent {
Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 10) {
Button("Finish") {
beginStopConfirmation(for: session)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
Button("Keep Monitoring") {
_ = appData.continueChargeSessionMonitoring(sessionID: session.id)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
}
.padding(14)
.meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
}
private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
let draftBelowPrediction: Bool = {
guard let draft = parsedDraftTarget, let predictedPercent else { return false }
return draft <= predictedPercent
}()
let savedBelowPrediction: Bool = {
guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
return saved <= predictedPercent
}()
return HStack(alignment: .center, spacing: 8) {
Image(systemName: "bell.badge")
.foregroundColor(.indigo)
.font(.subheadline)
Text("Notify at")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
if showingInlineTargetEditor {
targetEditorControls(
session: session,
draftBelowPrediction: draftBelowPrediction,
predictedPercent: predictedPercent
)
} else {
savedTargetControls(
session: session,
savedBelowPrediction: savedBelowPrediction,
predictedPercent: predictedPercent
)
}
}
}
private func targetEditorControls(
session: ChargeSessionSummary,
draftBelowPrediction: Bool,
predictedPercent: Double?
) -> some View {
Group {
Button {
let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
} label: {
Image(systemName: "minus.circle")
.font(.title3)
}
.buttonStyle(.plain)
TextField("-", text: $draftTargetText)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 48)
.multilineTextAlignment(.center)
.foregroundColor(draftBelowPrediction ? .orange : .primary)
Text("%")
.font(.subheadline)
.foregroundColor(.secondary)
if draftBelowPrediction, let predictedPercent {
predictionWarningButton(predictedPercent: predictedPercent)
}
Button {
let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
} label: {
Image(systemName: "plus.circle")
.font(.title3)
}
.buttonStyle(.plain)
Button {
if let value = parsedDraftTarget {
_ = appData.setTargetBatteryPercent(value, for: session.id)
}
showingInlineTargetEditor = false
} label: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
.font(.title3)
}
.buttonStyle(.plain)
.disabled(parsedDraftTarget == nil)
Button {
showingInlineTargetEditor = false
draftTargetText = ""
} label: {
Image(systemName: "xmark.circle")
.foregroundColor(.secondary)
.font(.title3)
}
.buttonStyle(.plain)
}
}
private func savedTargetControls(
session: ChargeSessionSummary,
savedBelowPrediction: Bool,
predictedPercent: Double?
) -> some View {
Group {
if let targetPercent = session.targetBatteryPercent {
Text("\(targetPercent.format(decimalDigits: 0))%")
.font(.subheadline.weight(.semibold))
.foregroundColor(savedBelowPrediction ? .orange : .indigo)
if savedBelowPrediction, let predictedPercent {
predictionWarningButton(predictedPercent: predictedPercent)
}
Button {
_ = appData.setTargetBatteryPercent(nil, for: session.id)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.callout)
}
.buttonStyle(.plain)
.help("Remove alert")
}
Button {
draftTargetText = session.targetBatteryPercent.map {
$0.format(decimalDigits: 0)
} ?? "80"
showingInlineTargetEditor = true
} label: {
Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
.font(.caption.weight(.semibold))
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
.meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
.buttonStyle(.plain)
.help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
}
}
private func predictionWarningButton(predictedPercent: Double) -> some View {
Button {} label: {
Image(systemName: "exclamationmark.triangle.fill")
.font(.callout.weight(.semibold))
.foregroundColor(.orange)
}
.buttonStyle(.plain)
.help("Battery is already predicted at \(predictedPercent.format(decimalDigits: 0))% - this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
}
private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
HStack(spacing: 10) {
if session.status == .active {
Button("Pause") {
_ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
}
.monitoringActionStyle(tint: .orange)
} else if session.status == .paused {
Button("Resume") {
_ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
}
.monitoringActionStyle(tint: .blue)
}
Button("Terminate Session") {
beginStopConfirmation(for: session)
}
.monitoringActionStyle(tint: .red)
}
}
private func stopConfirmPanel(
session: ChargeSessionSummary,
displayedEnergyWh: Double
) -> some View {
let canSave = hasSavableChargeData(
session: session,
displayedEnergyWh: displayedEnergyWh
)
let saveDisabledReason = saveDisabledReason(
session: session,
displayedEnergyWh: displayedEnergyWh
)
let isSaveEnabled = saveDisabledReason == nil
return VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Final Checkpoint")
.font(.subheadline.weight(.semibold))
Text("optional")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
}
finalCheckpointPicker(session)
if finalCheckpointMode == .custom {
customFinalCheckpointRow
}
if let saveDisabledReason {
Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundColor(.red)
.fixedSize(horizontal: false, vertical: true)
} else if let stopFailureMessage {
Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundColor(.red)
.fixedSize(horizontal: false, vertical: true)
} else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundColor(.green)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("Discard") {
discardSession(session)
}
.monitoringPanelActionStyle(tint: .secondary)
Button {
stopSession(
session,
displayedEnergyWh: displayedEnergyWh
)
} label: {
Label("Save Session", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
}
.monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
.disabled(!isSaveEnabled)
.help(saveDisabledReason ?? "Close and save this session")
Button("Cancel") {
resetStopConfirmation()
}
.monitoringPanelActionStyle(tint: .secondary)
}
}
.padding(14)
.meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
}
private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
return HStack(spacing: 8) {
ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
Button {
finalCheckpointMode = mode
if mode == .custom {
prefillFinalCheckpointIfNeeded(for: session)
} else {
finalCheckpointText = ""
}
} label: {
VStack(spacing: 5) {
Image(systemName: mode.icon)
.font(.title3)
.foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
Text(mode.label)
.font(.caption.weight(.semibold))
.foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
.meterCard(
tint: finalCheckpointMode == mode ? .primary : .secondary,
fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
cornerRadius: 12
)
}
.buttonStyle(.plain)
}
}
}
private var customFinalCheckpointRow: some View {
let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| parsedFinalCheckpoint == nil
return HStack(spacing: 8) {
Button {
adjustFinalCheckpoint(by: -1)
} label: {
Image(systemName: "minus.circle").font(.title3)
}
.buttonStyle(.plain)
TextField("-", text: $finalCheckpointText)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.multilineTextAlignment(.center)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
)
Text("%").foregroundColor(.secondary)
Text("required")
.font(.caption2.weight(.semibold))
.foregroundColor(isInvalid ? .red : .secondary)
Button {
adjustFinalCheckpoint(by: 1)
} label: {
Image(systemName: "plus.circle").font(.title3)
}
.buttonStyle(.plain)
Spacer()
}
}
private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
if let meterName = session.meterName {
MeterInfoRowView(label: "Controlled On", value: meterName)
}
Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
.font(.caption2)
.foregroundColor(.secondary)
}
}
private func managementCard(_ session: ChargeSessionSummary) -> some View {
MeterInfoCardView(title: "Administration", tint: .red) {
Button(role: .destructive) {
pendingSessionDeletion = session
} label: {
Label("Delete Session", systemImage: "trash")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
if let window = detectedTrimWindow {
HStack(spacing: 12) {
Image(systemName: "scissors.circle.fill")
.font(.title3)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Charging ended early")
.font(.subheadline.weight(.semibold))
Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
VStack(spacing: 6) {
Button("Trim Start") {
setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
trimBannerDismissedForSessionID = session.id
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(.blue)
Button("End & Finish") {
requestStop(
session,
applyingTrimStart: session.trimStart ?? window.start,
trimEnd: window.end,
title: "Trim End & Finish",
confirmTitle: "Finish",
explanation: "The detected charging window will be saved before the session is closed."
)
trimBannerDismissedForSessionID = session.id
}
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.blue.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
!session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
}
private func chartCard(
_ session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
ChargeSessionChartCardView(
session: session,
monitoringMeter: liveMonitoringMeter,
batteryPercentPoints: batteryPercentChartPoints(
for: session,
chargedDevice: chargedDevice
),
controlMode: chartControlMode(for: session),
onSetTrim: { start, end in
setSessionTrim(sessionID: session.id, start: start, end: end)
},
onStopWithTrim: { start, end in
requestStop(
session,
applyingTrimStart: start,
trimEnd: end,
title: "Trim End & Finish",
confirmTitle: "Finish",
explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
)
},
onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
? {
pendingTrimCommitSession = session
}
: nil
)
}
private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
if hasMonitoringControls {
return .activeMonitoring
}
if session.status.isOpen == false {
return .closed
}
return .none
}
private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
_ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
trimBannerDismissedForSessionID = sessionID
}
private func batteryPercentChartPoints(
for session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> [Measurements.Measurement.Point] {
var candidates: [BatteryPercentCandidate] = []
for sample in session.displayedAggregatedSamples {
let percent = chargedDevice.batteryLevelPrediction(
for: session,
effectiveEnergyWhOverride: effectiveBatteryEnergyWh(
rawMeasuredEnergyWh: sample.measuredEnergyWh,
for: session
),
referenceTimestamp: sample.timestamp
)?.predictedPercent
?? sample.estimatedBatteryPercent
if let percent, percent.isFinite {
candidates.append(
BatteryPercentCandidate(
timestamp: sample.timestamp,
percent: percent,
isCheckpoint: false
)
)
}
}
for checkpoint in session.checkpoints where session.effectiveTimeRange.contains(checkpoint.timestamp) {
guard checkpoint.batteryPercent.isFinite,
checkpoint.batteryPercent >= 0,
checkpoint.batteryPercent <= 100 else {
continue
}
candidates.append(
BatteryPercentCandidate(
timestamp: checkpoint.timestamp,
percent: checkpoint.batteryPercent,
isCheckpoint: true
)
)
}
if hasMonitoringControls,
let prediction = chargedDevice.batteryLevelPrediction(
for: session,
effectiveEnergyWhOverride: displayedSessionEnergyWh(for: session)
) {
candidates.append(
BatteryPercentCandidate(
timestamp: max(session.lastObservedAt, Date()),
percent: prediction.predictedPercent,
isCheckpoint: false
)
)
}
let sortedCandidates = coalescedBatteryPercentCandidates(candidates).sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.isCheckpoint && !rhs.isCheckpoint
}
var points: [Measurements.Measurement.Point] = []
var previousCandidate: BatteryPercentCandidate?
for candidate in sortedCandidates {
if let previousCandidate,
candidate.timestamp.timeIntervalSince(previousCandidate.timestamp) > 90 {
points.append(
Measurements.Measurement.Point(
id: points.count,
timestamp: candidate.timestamp,
value: points.last?.value ?? candidate.percent,
kind: .discontinuity
)
)
}
points.append(
Measurements.Measurement.Point(
id: points.count,
timestamp: candidate.timestamp,
value: min(max(candidate.percent, 0), 100)
)
)
previousCandidate = candidate
}
return points
}
private func coalescedBatteryPercentCandidates(
_ candidates: [BatteryPercentCandidate]
) -> [BatteryPercentCandidate] {
let sortedCandidates = candidates.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.isCheckpoint && !rhs.isCheckpoint
}
var coalesced: [BatteryPercentCandidate] = []
for candidate in sortedCandidates {
if let last = coalesced.last,
abs(candidate.timestamp.timeIntervalSince(last.timestamp)) <= 1 {
if candidate.isCheckpoint || !last.isCheckpoint {
coalesced[coalesced.count - 1] = candidate
}
} else {
coalesced.append(candidate)
}
}
return coalesced
}
private func effectiveBatteryEnergyWh(
rawMeasuredEnergyWh: Double,
for session: ChargeSessionSummary
) -> Double {
switch session.chargingTransportMode {
case .wired:
return rawMeasuredEnergyWh
case .wireless:
if let factor = session.wirelessEfficiencyFactor, factor > 0 {
return rawMeasuredEnergyWh * factor
}
if let effectiveEnergyWh = session.effectiveBatteryEnergyWh,
session.measuredEnergyWh > 0 {
return rawMeasuredEnergyWh * (effectiveEnergyWh / session.measuredEnergyWh)
}
return rawMeasuredEnergyWh
}
}
private func requestStop(
_ session: ChargeSessionSummary,
applyingTrimStart trimStart: Date?,
trimEnd: Date?,
title: String,
confirmTitle: String,
explanation: String
) {
pendingSessionStopRequest = ChargeSessionStopRequest(
sessionID: session.id,
title: title,
confirmTitle: confirmTitle,
explanation: explanation,
appliesTrim: trimStart != nil || trimEnd != nil,
trimStart: trimStart,
trimEnd: trimEnd
)
}
private var parsedDraftTarget: Double? {
let normalized = draftTargetText
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
return value
}
private var parsedFinalCheckpoint: Double? {
let normalized = finalCheckpointText
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
return value
}
private var resolvedFinalCheckpoint: Double? {
switch finalCheckpointMode {
case .full: return 100
case .skip: return nil
case .custom: return parsedFinalCheckpoint
}
}
private func adjustFinalCheckpoint(by delta: Double) {
let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
let next = min(max(current + delta, 0), 100)
finalCheckpointText = next.format(decimalDigits: 0)
}
private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
guard let session else { return nil }
if let endBatteryPercent = session.endBatteryPercent {
return endBatteryPercent
}
if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
return latestCheckpoint.batteryPercent
}
return session.targetBatteryPercent ?? session.completionContradictionPercent
}
private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
return
}
finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
}
private func hasSavableChargeData(
session: ChargeSessionSummary,
displayedEnergyWh: Double
) -> Bool {
session.hasSavableChargeData
|| displayedEnergyWh > 0
}
private func saveDisabledReason(
session: ChargeSessionSummary,
displayedEnergyWh: Double
) -> String? {
if finalCheckpointMode == .custom {
let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return "Enter the final battery percentage or choose Skip."
}
if parsedFinalCheckpoint == nil {
return "Final battery percentage must be between 0 and 100."
}
}
guard hasSavableChargeData(
session: session,
displayedEnergyWh: displayedEnergyWh
) else {
return "This session has no charging data to save. Discard it instead."
}
return nil
}
private func stopSession(
_ session: ChargeSessionSummary,
displayedEnergyWh: Double
) {
stopFailureMessage = nil
if let saveDisabledReason = saveDisabledReason(
session: session,
displayedEnergyWh: displayedEnergyWh
) {
stopFailureMessage = saveDisabledReason
return
}
let didSave = appData.stopChargeSession(
sessionID: session.id,
finalBatteryPercent: resolvedFinalCheckpoint,
from: liveMonitoringMeter
)
if didSave {
resetStopConfirmation()
} else {
stopFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment."
}
}
private func beginStopConfirmation(for session: ChargeSessionSummary) {
finalCheckpointMode = .skip
finalCheckpointText = ""
stopFailureMessage = nil
showingStopConfirm = true
}
private func discardSession(_ session: ChargeSessionSummary) {
_ = appData.deleteChargeSession(sessionID: session.id)
resetStopConfirmation()
}
private func resetStopConfirmation() {
showingStopConfirm = false
finalCheckpointText = ""
finalCheckpointMode = .skip
stopFailureMessage = nil
}
private func syncMonitoringRestore() {
guard let session,
session.status.isOpen,
let liveMonitoringMeter,
session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
return
}
liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
}
private func runTrimDetection() {
guard hasMonitoringControls,
let session,
session.isTrimmed == false,
!session.aggregatedSamples.isEmpty else {
detectedTrimWindow = nil
return
}
let sessionEnd = session.endedAt ?? session.lastObservedAt
detectedTrimWindow = ChargingWindowDetector.detect(
samples: session.aggregatedSamples,
sessionStart: session.startedAt,
sessionEnd: sessionEnd
)
}
private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
guard session.isTrimmed == false else { return storedEnergyWh }
guard session.status.isOpen else { return storedEnergyWh }
guard let liveMonitoringMeter else { return storedEnergyWh }
guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
if let baselineEnergyWh = session.meterEnergyBaselineWh {
return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
}
return storedEnergyWh
}
private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
let storedDuration = max(session.effectiveDuration, 0)
guard session.isTrimmed == false else { return storedDuration }
guard session.status.isOpen else { return storedDuration }
guard let liveMonitoringMeter else { return storedDuration }
guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
}
private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
let displayedDuration = displayedSessionDuration(for: session)
let formatter = DateComponentsFormatter()
formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .dropAll
return formatter.string(from: displayedDuration) ?? "0m"
}
private func formatDuration(_ duration: TimeInterval) -> String {
let totalSeconds = Int(duration.rounded(.down))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
private func autoStopDescription(for session: ChargeSessionSummary) -> String {
if session.autoStopEnabled == false {
return "Manual"
}
if let sessionWarning = sessionWarning(for: session),
sessionWarning.contains("idle-current") {
return "Blocked by charger setup"
}
if session.stopThresholdAmps > 0 {
return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
}
return "Learning"
}
private func autoStopLabel(for session: ChargeSessionSummary) -> String {
if session.autoStopEnabled == false {
return "Manual"
}
if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
}
if session.stopThresholdAmps > 0 {
return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
}
return "Learning"
}
private func shouldShowChargingTransport(
for session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> Bool {
chargedDevice.supportedChargingModes.count > 1
|| chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
}
private func shouldShowChargingState(
for session: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> Bool {
chargedDevice.supportedChargingStateModes.count > 1
|| chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
}
private func batteryColor(for percent: Double) -> Color {
if percent >= 75 { return .green }
if percent >= 35 { return .orange }
return .red
}
private func etaText(
rateWhPerSec: Double?,
remainingWh: Double,
isRelevant: Bool
) -> String? {
guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
let seconds = remainingWh / rateWhPerSec
return seconds > 120 ? formatETA(seconds) : nil
}
private func etaToTargetText(
session: ChargeSessionSummary,
prediction: BatteryLevelPrediction,
displayedEnergyWh: Double,
rateWhPerSec: Double?
) -> String? {
guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
return nil
}
let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
return etaText(
rateWhPerSec: rateWhPerSec,
remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
isRelevant: true
)
}
private func formatETA(_ seconds: TimeInterval) -> String {
let totalMinutes = Int(seconds / 60)
if totalMinutes < 60 { return "\(totalMinutes)m" }
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
}
private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
switch session.status {
case .active:
return .red
case .paused:
return .orange
case .completed:
return .green
case .abandoned:
return .secondary
}
}
private func sessionWarning(for session: ChargeSessionSummary) -> String? {
nil
}
private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
guard session.chargingTransportMode == .wireless else {
return nil
}
var components: [String] = []
if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
}
if session.usesEstimatedWirelessEfficiency {
components.append("Estimated from wired baseline and checkpoints")
}
if session.shouldWarnAboutLowWirelessEfficiency {
components.append("Low wireless efficiency, so capacity confidence is reduced")
}
return components.isEmpty ? nil : components.joined(separator: " - ")
}
private func statusTint(for session: ChargeSessionSummary) -> Color {
switch session.status {
case .active:
return .green
case .paused:
return .orange
case .completed:
return .teal
case .abandoned:
return .secondary
}
}
}
enum ChargeSessionChartControlMode {
case none
case activeMonitoring
case closed
}
struct ChargeSessionChartCardView: View {
let session: ChargeSessionSummary
let monitoringMeter: Meter?
let batteryPercentPoints: [Measurements.Measurement.Point]
let controlMode: ChargeSessionChartControlMode
let onSetTrim: (Date?, Date?) -> Void
let onStopWithTrim: (Date?, Date?) -> Void
let onCommitTrim: (() -> Void)?
@StateObject private var storedMeasurements = Measurements()
private var chartMeasurements: Measurements {
if let monitoringMeter,
session.status.isOpen,
session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
return monitoringMeter.chargeRecordMeasurements
}
return storedMeasurements
}
private var fullTimeRange: ClosedRange<Date> {
let start = session.startedAt
let end = max(session.endedAt ?? session.lastObservedAt, start)
return start...end
}
private var fixedTimeRange: ClosedRange<Date>? {
if monitoringMeter != nil && session.status.isOpen {
return nil
}
return session.effectiveTimeRange
}
private var liveTrimBounds: (lower: Date?, upper: Date?) {
guard monitoringMeter != nil && session.status.isOpen else {
return (nil, nil)
}
return (session.trimStart, session.trimEnd)
}
private var showsRangeSelector: Bool {
controlMode != .none && !session.aggregatedSamples.isEmpty
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "chart.xyaxis.line")
.foregroundColor(.blue)
Text("Session Chart")
.font(.headline)
ContextInfoButton(
title: "Session Chart",
message: chartInfoMessage
)
Spacer(minLength: 0)
}
MeasurementChartView(
timeRange: fixedTimeRange,
timeRangeLowerBound: liveTrimBounds.lower,
timeRangeUpperBound: liveTrimBounds.upper,
showsRangeSelector: showsRangeSelector,
rebasesEnergyToVisibleRangeStart: true,
extendsTimelineToPresent: false,
showsTemperatureSeries: false,
showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
batteryCheckpoints: session.checkpoints,
batteryPercentPoints: batteryPercentPoints,
rangeSelectorConfiguration: rangeSelectorConfiguration
)
.environmentObject(chartMeasurements)
.frame(maxWidth: .infinity, alignment: .topLeading)
if let onCommitTrim {
Divider()
HStack(alignment: .center, spacing: 10) {
Label("Save trim permanently", systemImage: "internaldrive")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
Spacer(minLength: 0)
Button {
onCommitTrim()
} label: {
Label("Save Trim", systemImage: "checkmark.seal")
.font(.caption.weight(.semibold))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(.red)
}
}
}
.padding(18)
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
.onAppear(perform: restoreStoredMeasurementsIfNeeded)
.onChange(of: session.id) { _ in
restoreStoredMeasurementsIfNeeded()
}
.onChange(of: session.aggregatedSamples.count) { _ in
restoreStoredMeasurementsIfNeeded()
}
.onChange(of: session.checkpoints.count) { _ in
restoreStoredMeasurementsIfNeeded()
}
}
private var chartInfoMessage: String {
if monitoringMeter != nil && session.status.isOpen {
return "This chart combines the persisted session curve with current live data from this meter."
}
return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
}
private var shouldShowBatteryPercentSeries: Bool {
!batteryPercentPoints.isEmpty
}
private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
switch controlMode {
case .none:
return nil
case .activeMonitoring:
return MeasurementChartRangeSelectorConfiguration(
keepAction: MeasurementChartSelectionAction(
title: "Trim Start",
shortTitle: "Start",
systemName: "arrow.right.to.line",
tone: .destructive,
handler: applyActiveStartTrim
),
removeAction: MeasurementChartSelectionAction(
title: "Trim End & Finish",
shortTitle: "End",
systemName: "arrow.left.to.line",
tone: .destructiveProminent,
handler: requestActiveEndTrim
),
resetAction: MeasurementChartResetAction(
title: "Reset Trim",
shortTitle: "Reset",
systemName: "arrow.counterclockwise",
tone: .reversible,
confirmationTitle: "Reset session trim?",
confirmationButtonTitle: "Reset trim",
handler: {
onSetTrim(nil, nil)
}
)
)
case .closed:
return MeasurementChartRangeSelectorConfiguration(
keepAction: MeasurementChartSelectionAction(
title: "Trim Window",
shortTitle: "Trim",
systemName: "scissors",
tone: .destructive,
handler: applyClosedTrim
),
removeAction: nil,
resetAction: MeasurementChartResetAction(
title: "Reset Trim",
shortTitle: "Reset",
systemName: "arrow.counterclockwise",
tone: .reversible,
confirmationTitle: "Reset session trim?",
confirmationButtonTitle: "Reset trim",
handler: {
onSetTrim(nil, nil)
}
)
)
}
}
private func restoreStoredMeasurementsIfNeeded() {
guard monitoringMeter == nil || session.status.isOpen == false else {
return
}
storedMeasurements.resetSeries()
_ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
from: session,
replacingLiveBufferIfNeeded: true
)
}
private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
}
private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
let start = session.trimStart ?? normalizedStart(range.lowerBound)
let end = normalizedEnd(range.upperBound)
onStopWithTrim(start, end)
}
private func applyClosedTrim(_ range: ClosedRange<Date>) {
onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
}
private func normalizedStart(_ date: Date) -> Date? {
date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
}
private func normalizedEnd(_ date: Date) -> Date? {
fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
}
}
private struct ChargeSessionStopRequest: Identifiable {
let sessionID: UUID
let title: String
let confirmTitle: String
let explanation: String
let appliesTrim: Bool
let trimStart: Date?
let trimEnd: Date?
var id: String {
[
sessionID.uuidString,
title,
trimStart?.timeIntervalSince1970.description ?? "nil",
trimEnd?.timeIntervalSince1970.description ?? "nil"
].joined(separator: "-")
}
}
private extension View {
func monitoringActionStyle(tint: Color) -> some View {
frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(
tint: tint,
fillOpacity: isProminent ? 0.22 : 0.10,
strokeOpacity: isProminent ? 0.32 : 0.14,
cornerRadius: 14
)
.buttonStyle(.plain)
}
}