1 contributor
//
// MeterChargeRecordTabView.swift
// USB Meter
//
import SwiftUI
struct MeterChargeRecordTabView: View, Equatable {
static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
true
}
var body: some View {
MeterChargeRecordContentView()
}
}
private struct SessionChartWidthPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 760
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let next = nextValue()
if next > 0 {
value = next
}
}
}
struct MeterChargeRecordContentView: View {
private struct SessionMetricRow {
let label: String
let value: String
}
private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
case known
case unknown
case flat
var id: String { rawValue }
var title: String {
switch self {
case .known: return "Known"
case .unknown: return "Unknown"
case .flat: return "Flat"
}
}
}
private enum ActiveMode: Hashable {
case chargeSession
case standbyPower
}
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 enum SessionStartRequirement: Identifiable {
case existingSession
case device
case chargingType
case chargingMode
case charger
case initialCheckpointEmpty
case initialCheckpointInvalid
var id: String {
switch self {
case .existingSession: return "existing-session"
case .device: return "device"
case .chargingType: return "charging-type"
case .chargingMode: return "charging-mode"
case .charger: return "charger"
case .initialCheckpointEmpty: return "initial-checkpoint-empty"
case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
}
}
var message: String {
switch self {
case .existingSession: return "Stop or pause the current session before starting another one."
case .device: return "Select the device that is charging."
case .chargingType: return "Choose the charging type for this session."
case .chargingMode: return "Choose whether the device is on or off for this session."
case .charger: return "Select the wireless charger used in this session."
case .initialCheckpointEmpty: return "Enter the initial battery percentage."
case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
}
}
}
@EnvironmentObject private var appData: AppData
@EnvironmentObject private var usbMeter: Meter
@State private var showingInlineTargetEditor = false
@State private var draftTargetText = ""
@State private var showingStopConfirm = false
@State private var finalCheckpointMode: FinalCheckpoint = .skip
@State private var finalCheckpointText = ""
@State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
@State private var draftChargingTransportMode: ChargingTransportMode?
@State private var draftChargingStateMode: ChargingStateMode?
@State private var initialCheckpointMode: InitialCheckpointMode = .known
@State private var initialCheckpoint = ""
@State private var showsMeterTotalsInfo = false
@State private var activeMode: ActiveMode = .chargeSession
@State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
@State private var trimBannerDismissedForSessionID: UUID?
@State private var sessionChartWidth: CGFloat = 760
private var shouldShowTrimBanner: Bool {
guard let session = openChargeSession,
session.isTrimmed == false,
trimBannerDismissedForSessionID != session.id else { return false }
guard let window = detectedTrimWindow else { return false }
return window.trimRatio > ChargingWindowDetector.significantTrimThreshold
}
var body: some View {
ScrollView {
VStack(spacing: 14) {
statusHeader
if let openChargeSession {
chargingMonitorCard(openChargeSession)
if shouldShowTrimBanner {
trimDetectionBanner(for: openChargeSession)
}
if shouldShowSessionChart(for: openChargeSession) {
sessionChartCard(
timeRange: sessionChartFixedTimeRange(for: openChargeSession),
session: openChargeSession
)
}
} else {
liveMeterStripView
modePicker
switch activeMode {
case .chargeSession:
chargeSessionSetupCard
case .standbyPower:
standbyPowerCard
}
}
}
.padding()
}
.background(
LinearGradient(
colors: [.pink.opacity(0.14), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.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")) {
if let openChargeSession {
_ = appData.deleteBatteryCheckpoint(
checkpointID: checkpoint.id,
for: openChargeSession.id
)
}
},
secondaryButton: .cancel()
)
}
.onAppear {
syncActiveSessionRestore()
syncDraftSelections()
runTrimDetection()
}
.onChange(of: selectedChargedDevice?.id) { _ in
syncDraftSelections()
}
.onChange(of: openChargeSession?.id) { _ in
syncActiveSessionRestore()
syncDraftSelections()
showingInlineTargetEditor = false
draftTargetText = ""
detectedTrimWindow = nil
trimBannerDismissedForSessionID = nil
runTrimDetection()
}
.onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
syncActiveSessionRestore()
runTrimDetection()
}
}
private func syncActiveSessionRestore() {
guard let session = openChargeSession else { return }
guard session.status == .active else { return }
guard session.meterMACAddress == meterMACAddress else { return }
usbMeter.restoreChargeRecordIfNeeded(from: session)
}
private func runTrimDetection() {
guard let session = openChargeSession,
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
)
}
// MARK: - Computed Properties
private var meterMACAddress: String {
usbMeter.btSerial.macAddress.description
}
private var selectedChargedDevice: ChargedDeviceSummary? {
appData.currentChargedDeviceSummary(for: meterMACAddress)
}
private var availableChargedDevices: [ChargedDeviceSummary] {
appData.deviceSummaries
}
private var selectedChargedDeviceID: Binding<UUID?> {
Binding(
get: { selectedChargedDevice?.id },
set: { newValue in
guard let newValue else { return }
_ = appData.assignChargedDevice(newValue, to: meterMACAddress)
}
)
}
private var selectedCharger: ChargedDeviceSummary? {
appData.currentChargerSummary(for: meterMACAddress)
}
private var availableChargers: [ChargedDeviceSummary] {
appData.chargerSummaries
}
private var selectedChargerID: Binding<UUID?> {
Binding(
get: { selectedCharger?.id },
set: { newValue in
guard let newValue else { return }
_ = appData.assignCharger(newValue, to: meterMACAddress)
}
)
}
private var openChargeSession: ChargeSessionSummary? {
appData.activeChargeSessionSummary(for: meterMACAddress)
}
private var showsMeterTotalsCard: Bool {
usbMeter.supportsRecordingView
|| usbMeter.supportsDataGroupCommands
|| usbMeter.recordedAH > 0
|| usbMeter.recordedWH > 0
|| usbMeter.recordingDuration > 0
}
private var selectedDraftTransportMode: ChargingTransportMode? {
openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
}
private var selectedDraftChargingStateMode: ChargingStateMode? {
openChargeSession?.chargingStateMode ?? draftChargingStateMode
}
private var initialCheckpointValue: Double? {
guard initialCheckpointMode == .known else { return nil }
let normalized = initialCheckpoint
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
return value
}
private var hasInitialCheckpointInput: Bool {
initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private var shouldRequireInitialCheckpoint: Bool {
initialCheckpointMode == .known
}
private var requiresExplicitTransportSelection: Bool {
(selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
}
private var requiresExplicitChargingStateSelection: Bool {
(selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
}
private var startRequirements: [SessionStartRequirement] {
var requirements: [SessionStartRequirement] = []
if openChargeSession != nil {
requirements.append(.existingSession)
}
guard let selectedChargedDevice else {
requirements.append(.device)
return requirements
}
guard let chargingTransportMode = selectedDraftTransportMode else {
requirements.append(.chargingType)
return requirements
}
if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
requirements.append(.chargingType)
}
guard let chargingStateMode = selectedDraftChargingStateMode else {
requirements.append(.chargingMode)
return requirements
}
if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
requirements.append(.chargingMode)
}
if chargingTransportMode == .wireless, selectedCharger == nil {
requirements.append(.charger)
}
if shouldRequireInitialCheckpoint {
if hasInitialCheckpointInput == false {
requirements.append(.initialCheckpointEmpty)
} else if initialCheckpointValue == nil {
requirements.append(.initialCheckpointInvalid)
}
}
return requirements
}
private var canStartSession: Bool {
startRequirements.isEmpty
}
private var headerStatusTitle: String {
guard let openChargeSession else { return "Idle" }
return openChargeSession.status.title
}
private var headerStatusColor: Color {
guard let openChargeSession else { return .secondary }
switch openChargeSession.status {
case .active: return .red
case .paused: return .orange
case .completed: return .green
case .abandoned: return .secondary
}
}
private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session)
}
private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
if usesChargeRecordBuffer(for: session) {
return nil
}
return session.effectiveTimeRange
}
private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
guard usesChargeRecordBuffer(for: session) else {
return (nil, nil)
}
return (session.trimStart, session.trimEnd)
}
private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
session.status.isOpen && session.meterMACAddress == meterMACAddress
}
private var showsWirelessChargerSection: Bool {
let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
return transportMode == .wireless
}
// MARK: - Status Header
private var statusHeader: some View {
HStack {
Image(systemName: "bolt.fill")
.foregroundColor(.pink)
Text("Charging Session")
.font(.system(.title3, design: .rounded).weight(.bold))
Spacer()
Text(headerStatusTitle)
.font(.caption.weight(.bold))
.foregroundColor(headerStatusColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
}
// MARK: - Mode Picker
private var modePicker: some View {
Picker("", selection: $activeMode) {
Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
}
.pickerStyle(.segmented)
.labelsHidden()
}
// MARK: - Charge Session Setup
private var chargeSessionSetupCard: some View {
VStack(alignment: .leading, spacing: 0) {
// Device
setupRow(icon: "iphone", iconColor: .blue) {
Picker(selection: selectedChargedDeviceID) {
Text("Choose device").tag(UUID?.none)
ForEach(availableChargedDevices) { device in
Text(device.name).tag(Optional(device.id))
}
} label: {
HStack(spacing: 8) {
if let device = selectedChargedDevice {
ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
.font(.subheadline.weight(.semibold))
} else {
Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
.foregroundColor(.secondary)
.font(.subheadline)
}
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
.pickerStyle(.menu)
.disabled(availableChargedDevices.isEmpty)
}
// Charging type — only when device supports multiple
if requiresExplicitTransportSelection, let device = selectedChargedDevice {
Divider().padding(.leading, 46)
setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
Text("Type")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
compactSelectionMenu(
title: draftChargingTransportMode?.title ?? "Choose",
options: device.supportedChargingModes.map { mode in
CompactSelectionOption(
id: mode.id, title: mode.title,
isSelected: draftChargingTransportMode == mode,
action: { draftChargingTransportMode = mode }
)
}
)
}
}
// Charging state — only when device supports multiple
if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
Divider().padding(.leading, 46)
setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
Text("Mode")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
compactSelectionMenu(
title: draftChargingStateMode?.title ?? "Choose",
options: device.supportedChargingStateModes.map { mode in
CompactSelectionOption(
id: mode.id, title: mode.title,
isSelected: draftChargingStateMode == mode,
action: { draftChargingStateMode = mode }
)
}
)
}
}
// Wireless charger — only when wireless transport
if showsWirelessChargerSection {
Divider().padding(.leading, 46)
setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Picker(selection: selectedChargerID) {
Text("Choose charger").tag(UUID?.none)
ForEach(availableChargers) { charger in
Text(charger.name).tag(Optional(charger.id))
}
} label: {
HStack(spacing: 8) {
if let charger = selectedCharger {
ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
.font(.subheadline.weight(.semibold))
if charger.chargerIdleCurrentAmps == nil {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.caption)
}
} else {
Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
.foregroundColor(.secondary)
.font(.subheadline)
}
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
.pickerStyle(.menu)
.disabled(availableChargers.isEmpty)
}
}
// Battery checkpoint
Divider().padding(.leading, 46)
setupRow(icon: "battery.75percent", iconColor: .green) {
if initialCheckpointMode == .known {
Button { adjustInitialCheckpoint(by: -1) } label: {
Image(systemName: "minus.circle").font(.title3)
}
.buttonStyle(.plain)
TextField("—", text: $initialCheckpoint)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 52)
.multilineTextAlignment(.center)
Text("%")
.font(.subheadline)
.foregroundColor(.secondary)
Button { adjustInitialCheckpoint(by: 1) } label: {
Image(systemName: "plus.circle").font(.title3)
}
.buttonStyle(.plain)
} else {
Text(initialCheckpointMode == .flat
? "Flat (device off / discharged)"
: "Unknown")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
compactSelectionMenu(
title: initialCheckpointMode.title,
options: InitialCheckpointMode.allCases.map { mode in
CompactSelectionOption(
id: mode.id, title: mode.title,
isSelected: initialCheckpointMode == mode,
action: { initialCheckpointMode = mode }
)
}
)
}
// Requirement errors
if startRequirements.isEmpty == false {
Divider()
VStack(alignment: .leading, spacing: 6) {
ForEach(startRequirements) { requirement in
Label(requirement.message, systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundColor(.orange)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
// Start button
Divider()
Button("Start Session") {
startSession()
}
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.font(.subheadline.weight(.semibold))
.foregroundColor(canStartSession ? .green : .secondary)
.buttonStyle(.plain)
.disabled(!canStartSession)
}
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
}
// MARK: - Standby Power Card
private var standbyPowerCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "powersleep")
.foregroundColor(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Charger Standby Power")
.font(.subheadline.weight(.semibold))
Text("Measure idle draw with no device connected.")
.font(.caption)
.foregroundColor(.secondary)
}
}
NavigationLink(
destination: ChargerStandbyPowerWizardView(
preferredMeterMACAddress: meterMACAddress
)
) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.orange)
Text("New Measurement")
.font(.subheadline.weight(.semibold))
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
.meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
}
.buttonStyle(.plain)
}
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
}
// MARK: - Live Meter Strip (idle state)
private var liveMeterStripView: some View {
let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
return LazyVGrid(columns: columns, spacing: 8) {
metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
}
}
// MARK: - Charging Monitor Card
private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
for: openChargeSession,
effectiveEnergyWhOverride: displayedEnergyWh
)
return VStack(alignment: .leading, spacing: 14) {
// Header
HStack {
if let device = selectedChargedDevice {
ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
.font(.headline)
} else {
Text("Charging Monitor").font(.headline)
}
Spacer()
Text(openChargeSession.status.title)
.font(.caption.weight(.bold))
.foregroundColor(headerStatusColor)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
}
// Orphaned session warning — device was deleted from library
if selectedChargedDevice == nil {
VStack(alignment: .leading, spacing: 8) {
Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
Text("The device associated with this session no longer exists. Stop the session to close it.")
.font(.caption)
.foregroundColor(.secondary)
Button("Terminate Session") {
_ = appData.stopChargeSession(
sessionID: openChargeSession.id,
finalBatteryPercent: nil
)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
.buttonStyle(.plain)
}
.padding(14)
.meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
}
// Battery prediction gauge
if let batteryPrediction {
batteryGaugeSection(
prediction: batteryPrediction,
session: openChargeSession,
displayedEnergyWh: displayedEnergyWh
)
}
// Metrics grid
sessionMetricsGrid(
for: openChargeSession,
displayedEnergyWh: displayedEnergyWh,
hasPrediction: batteryPrediction != nil
)
if openChargeSession.stopThresholdAmps > 0 {
Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
.font(.caption)
.foregroundColor(.secondary)
}
if let sessionWarning = sessionWarning(for: openChargeSession) {
Label(sessionWarning, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundColor(.orange)
}
if openChargeSession.isPaused {
Label(
"Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
systemImage: "pause.circle"
)
.font(.caption)
.foregroundColor(.secondary)
}
if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
completionConfirmationCard(openChargeSession)
}
BatteryCheckpointSectionView(
sessionID: openChargeSession.id,
checkpoints: openChargeSession.checkpoints,
message: "Checkpoints are used for capacity estimation and the typical charge curve.",
canAddCheckpoint: canAddCheckpoint,
requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
effectiveEnergyWhOverride: displayedEnergyWh,
measuredChargeAhOverride: displayedChargeAh,
onDelete: { checkpoint in
pendingCheckpointDeletion = checkpoint
}
)
targetSectionView(
for: openChargeSession,
predictedPercent: batteryPrediction?.predictedPercent
)
if showingStopConfirm {
stopConfirmPanel(for: openChargeSession)
} else {
HStack(spacing: 10) {
if openChargeSession.status == .active {
Button("Pause") {
_ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
} else if openChargeSession.status == .paused {
Button("Resume") {
_ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
Button("Terminate Session") {
finalCheckpointMode = .skip
finalCheckpointText = ""
showingStopConfirm = true
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
}
}
.padding(18)
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
}
// MARK: - Battery Gauge Section
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: String? = {
guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
let seconds = remaining / rate
return seconds > 120 ? formatETA(seconds) : nil
}()
let etaToTarget: String? = {
guard let target = session.targetBatteryPercent, target > percent + 1,
let rate = rateWhPerSec, rate > 0.0001 else { return nil }
let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
let seconds = remaining / rate
return seconds > 120 ? formatETA(seconds) : nil
}()
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 {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.caption)
.foregroundColor(.green)
Text(etaToFull)
.font(.caption.weight(.bold))
}
Text("to full")
.font(.caption2)
.foregroundColor(.secondary)
}
}
if let etaToTarget, let target = session.targetBatteryPercent {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "bell.badge.fill")
.font(.caption)
.foregroundColor(.indigo)
Text(etaToTarget)
.font(.caption.weight(.bold))
}
Text("to \(Int(target.rounded()))%")
.font(.caption2)
.foregroundColor(.secondary)
}
}
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 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 batteryColor(for percent: Double) -> Color {
if percent >= 75 { return .green }
if percent >= 35 { return .orange }
return .red
}
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"
}
// MARK: - Session Metrics Grid
private func sessionMetricsGrid(
for session: ChargeSessionSummary,
displayedEnergyWh: Double,
hasPrediction: Bool
) -> some View {
let displayedDuration = displayedSessionDuration(for: session)
let capacityFallback: Double? = hasPrediction ? nil : (
session.capacityEstimateWh
?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
?? selectedChargedDevice?.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(displayedDuration), tint: .teal)
if shouldShowChargingTransport(for: session) {
metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
}
if shouldShowChargingState(for: session) {
metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
}
metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
if let capacity = capacityFallback {
metricCell(label: "Est. Capacity", value: "\(capacity.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(_ openChargeSession: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
.font(.subheadline.weight(.semibold))
if let contradictionPercent = openChargeSession.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") {
finalCheckpointMode = .skip
finalCheckpointText = ""
showingStopConfirm = true
}
.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: openChargeSession.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)
}
// MARK: - Target Section
private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
let draftBelowPrediction: Bool = {
guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
return draft <= predicted
}()
let savedBelowPrediction: Bool = {
guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
return saved <= predicted
}()
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 {
Button {
let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
let next = max(current - 1, 1)
draftTargetText = next.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 {
Button {} label: {
Image(systemName: "exclamationmark.triangle.fill")
.font(.body.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.")
}
Button {
let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
let next = min(current + 1, 100)
draftTargetText = next.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)
} else {
if let targetPercent = session.targetBatteryPercent {
Text("\(targetPercent.format(decimalDigits: 0))%")
.font(.subheadline.weight(.semibold))
.foregroundColor(savedBelowPrediction ? .orange : .indigo)
if savedBelowPrediction {
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.")
}
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 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 func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Final Checkpoint (optional)")
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
Button {
finalCheckpointMode = mode
if mode != .custom { 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)
}
}
if finalCheckpointMode == .custom {
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)
Text("%").foregroundColor(.secondary)
Button { adjustFinalCheckpoint(by: 1) } label: {
Image(systemName: "plus.circle").font(.title3)
}
.buttonStyle(.plain)
Spacer()
}
}
HStack(spacing: 8) {
Button("Discard") {
_ = appData.deleteChargeSession(sessionID: session.id)
showingStopConfirm = false
finalCheckpointText = ""
finalCheckpointMode = .full
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
.buttonStyle(.plain)
let saveDisabled = finalCheckpointMode == .custom
&& finalCheckpointText.isEmpty == false
&& parsedFinalCheckpoint == nil
Button("Save") {
_ = appData.stopChargeSession(
sessionID: session.id,
finalBatteryPercent: resolvedFinalCheckpoint
)
showingStopConfirm = false
finalCheckpointText = ""
finalCheckpointMode = .full
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
.disabled(saveDisabled)
Button("Cancel") {
showingStopConfirm = false
finalCheckpointText = ""
finalCheckpointMode = .full
}
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
.buttonStyle(.plain)
}
}
.padding(14)
.meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
}
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.0
case .skip: return nil
case .custom: return parsedFinalCheckpoint
}
}
private func adjustFinalCheckpoint(by delta: Double) {
let current = parsedFinalCheckpoint ?? 0
let next = min(max(current + delta, 0), 100)
finalCheckpointText = next.format(decimalDigits: 0)
}
// MARK: - Trim Detection Banner
@ViewBuilder
private func trimDetectionBanner(for 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 detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
VStack(spacing: 6) {
Button("Apply") {
_ = appData.setSessionTrim(
sessionID: session.id,
start: window.start,
end: window.end
)
trimBannerDismissedForSessionID = session.id
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(.blue)
Button {
trimBannerDismissedForSessionID = session.id
} label: {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.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 sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
let hasRangeSelector = session.aggregatedSamples.isEmpty == false
let chartWidth = max(sessionChartWidth, 1)
let compactChartLayout = MeasurementChartView.prefersCompactEmbeddedLayout(forWidth: chartWidth)
let plotReferenceHeight = MeasurementChartView.embeddedPlotReferenceHeight(compactLayout: compactChartLayout)
return 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: usesChargeRecordBuffer(for: session)
? "This chart combines the persisted session curve with current live data from this meter."
: "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
)
Spacer(minLength: 0)
}
MeasurementChartView(
compactLayout: compactChartLayout,
availableSize: CGSize(width: chartWidth, height: plotReferenceHeight),
timeRange: timeRange,
timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
showsRangeSelector: hasRangeSelector,
rebasesEnergyToVisibleRangeStart: true,
extendsTimelineToPresent: false,
rangeSelectorConfiguration: hasRangeSelector
? MeasurementChartRangeSelectorConfiguration(
keepAction: MeasurementChartSelectionAction(
title: compactChartLayout ? "Keep" : "Keep Selection",
systemName: "scissors",
tone: .destructive,
handler: { range in
_ = appData.setSessionTrim(
sessionID: session.id,
start: range.lowerBound,
end: range.upperBound
)
trimBannerDismissedForSessionID = session.id
}
),
removeAction: nil,
resetAction: MeasurementChartResetAction(
title: compactChartLayout ? "Reset" : "Reset Trim",
systemName: "arrow.counterclockwise",
tone: .reversible,
confirmationTitle: "Reset session trim?",
confirmationButtonTitle: "Reset trim",
handler: {
_ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
}
)
)
: nil
)
.environmentObject(usbMeter.chargeRecordMeasurements)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: MeasurementChartView.embeddedContentHeight(width: chartWidth, showsRangeSelector: hasRangeSelector))
.background(
GeometryReader { geometry in
Color.clear.preference(key: SessionChartWidthPreferenceKey.self, value: geometry.size.width)
}
)
.onPreferenceChange(SessionChartWidthPreferenceKey.self) { width in
guard width > 0, abs(width - sessionChartWidth) > 0.5 else { return }
sessionChartWidth = width
}
}
.padding(18)
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
}
private var meterTotalsCard: some View {
return VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("Meter Recorder")
.font(.headline)
Spacer(minLength: 0)
Button {
showsMeterTotalsInfo.toggle()
} label: {
Image(systemName: "info.circle")
.font(.body.weight(.semibold))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Meter recorder info")
.popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
VStack(alignment: .leading, spacing: 10) {
Text("Meter Recorder")
.font(.headline)
Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
.frame(width: 280, alignment: .leading)
}
}
ChargeRecordMetricsTableView(
labels: ["Energy", "Duration", "Meter Threshold"],
values: [
"\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
usbMeter.recordingDurationDescription,
usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
]
)
if let recordingBootedAt = usbMeter.recordingBootedAt {
Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(18)
.meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
}
// MARK: - Helpers
private func setupRow<Content: View>(
icon: String,
iconColor: Color = .secondary,
@ViewBuilder content: () -> Content
) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.foregroundColor(iconColor)
.font(.body.weight(.medium))
.frame(width: 22, alignment: .center)
content()
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
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 sessionMetricRows(
for session: ChargeSessionSummary,
displayedEnergyWh: Double
) -> [SessionMetricRow] {
var rows: [SessionMetricRow] = []
if shouldShowChargingTransport(for: session) {
rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
}
if shouldShowChargingState(for: session) {
rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
}
rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
return rows
}
private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
guard let selectedChargedDevice else { return true }
return selectedChargedDevice.supportedChargingModes.count > 1
|| selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
}
private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
guard let selectedChargedDevice else { return true }
return selectedChargedDevice.supportedChargingStateModes.count > 1
|| selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
}
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 session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
if let baselineEnergyWh = session.meterEnergyBaselineWh {
return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
}
return storedEnergyWh
}
private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
let storedChargeAh = session.measuredChargeAh
guard session.isTrimmed == false else { return storedChargeAh }
guard session.status.isOpen else { return storedChargeAh }
guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
if let baselineChargeAh = session.meterChargeBaselineAh {
return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
}
return storedChargeAh
}
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 session.meterMACAddress == meterMACAddress else { return storedDuration }
return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
}
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 sessionWarning(for session: ChargeSessionSummary) -> String? {
guard session.chargingTransportMode == .wireless,
let chargerID = session.chargerID,
let charger = appData.chargedDeviceSummary(id: chargerID) else {
return nil
}
guard charger.chargerIdleCurrentAmps == nil else { return nil }
return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
}
private func startSession() {
guard let selectedChargedDevice,
let chargingTransportMode = selectedDraftTransportMode,
let chargingStateMode = selectedDraftChargingStateMode else {
return
}
let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
let didStart = appData.startChargeSession(
for: usbMeter,
chargedDeviceID: selectedChargedDevice.id,
chargerID: chargerID,
chargingTransportMode: chargingTransportMode,
chargingStateMode: chargingStateMode,
autoStopEnabled: false,
initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
startsFromFlatBattery: initialCheckpointMode == .flat
)
if didStart {
initialCheckpoint = ""
initialCheckpointMode = .known
}
}
private func adjustInitialCheckpoint(by delta: Double) {
guard initialCheckpointMode == .known else { return }
let currentValue = initialCheckpointValue ?? 0
let nextValue = min(max(currentValue + delta, 0), 100)
initialCheckpoint = nextValue.format(decimalDigits: 0)
}
private func syncDraftSelections() {
guard let selectedChargedDevice else {
draftChargingTransportMode = nil
draftChargingStateMode = nil
return
}
if let openChargeSession {
draftChargingTransportMode = openChargeSession.chargingTransportMode
draftChargingStateMode = openChargeSession.chargingStateMode
return
}
if let draftChargingTransportMode,
selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
self.draftChargingTransportMode = nil
}
if let draftChargingStateMode,
selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
self.draftChargingStateMode = nil
}
if selectedChargedDevice.supportedChargingModes.count == 1 {
draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
}
if let draftChargingTransportMode {
draftChargingStateMode = draftChargingStateMode
?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
} else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
}
}
private struct CompactSelectionOption: Identifiable {
let id: String
let title: String
let isSelected: Bool
let action: () -> Void
}
private func compactSelectionMenu(
title: String,
options: [CompactSelectionOption]
) -> some View {
Menu {
ForEach(options) { option in
Button {
option.action()
} label: {
if option.isSelected {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
} label: {
HStack(spacing: 8) {
Text(title)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(width: 160, alignment: .leading)
.meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
}
.buttonStyle(.plain)
}
}