1 contributor
//
// MeterChargeRecordTabView.swift
// USB Meter
//
import SwiftUI
struct MeterChargeRecordTabView: View {
var body: some View {
MeterChargeRecordContentView()
}
}
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"
}
}
}
@EnvironmentObject private var appData: AppData
@EnvironmentObject private var usbMeter: Meter
@State private var chargedDeviceLibraryVisibility = false
@State private var chargerLibraryVisibility = false
@State private var editingChargedDevice: ChargedDeviceSummary?
@State private var targetNotificationEditorVisibility = false
@State private var pendingStopRequest: ChargeSessionStopRequest?
@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
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."
}
}
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
headerCard
sessionSetupCard
if let openChargeSession {
chargingMonitorCard(openChargeSession)
if showsMeterTotalsCard {
meterTotalsCard
}
if let sessionChartTimeRange {
sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession)
}
} else if showsMeterTotalsCard {
meterTotalsCard
}
}
.padding()
}
.background(
LinearGradient(
colors: [.pink.opacity(0.14), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.sheet(isPresented: $chargedDeviceLibraryVisibility) {
ChargedDeviceLibrarySheetView(
visibility: $chargedDeviceLibraryVisibility,
meterMACAddress: meterMACAddress,
meterTint: usbMeter.color,
mode: .device
)
.environmentObject(appData)
}
.sheet(isPresented: $chargerLibraryVisibility) {
ChargedDeviceLibrarySheetView(
visibility: $chargerLibraryVisibility,
meterMACAddress: meterMACAddress,
meterTint: usbMeter.color,
mode: .charger
)
.environmentObject(appData)
}
.sheet(item: $editingChargedDevice) { chargedDevice in
ChargedDeviceEditorSheetView(
meterMACAddress: nil,
kind: chargedDevice.kind,
chargedDevice: chargedDevice
)
.environmentObject(appData)
}
.sheet(isPresented: $targetNotificationEditorVisibility) {
if let openChargeSession {
BatteryTargetNotificationEditorSheetView(
sessionID: openChargeSession.id,
initialTargetPercent: openChargeSession.targetBatteryPercent
)
.environmentObject(appData)
}
}
.sheet(item: $pendingStopRequest) { request in
ChargeSessionCompletionSheetView(
sessionID: request.sessionID,
title: request.title,
confirmTitle: request.confirmTitle,
explanation: request.explanation
)
.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")) {
if let openChargeSession {
_ = appData.deleteBatteryCheckpoint(
checkpointID: checkpoint.id,
for: openChargeSession.id
)
}
},
secondaryButton: .cancel()
)
}
.onAppear {
syncDraftSelections()
}
.onChange(of: selectedChargedDevice?.id) { _ in
syncDraftSelections()
}
.onChange(of: openChargeSession?.id) { _ in
syncDraftSelections()
}
}
private var meterMACAddress: String {
usbMeter.btSerial.macAddress.description
}
private var selectedChargedDevice: ChargedDeviceSummary? {
appData.currentChargedDeviceSummary(for: meterMACAddress)
}
private var selectedCharger: ChargedDeviceSummary? {
appData.currentChargerSummary(for: 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 var sessionChartTimeRange: ClosedRange<Date>? {
guard let openChargeSession else {
return nil
}
let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
}
private var headerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Charging Session")
.font(.system(.title3, design: .rounded).weight(.bold))
ContextInfoButton(
title: "Charging Session",
message: "Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit."
)
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
)
}
}
.frame(maxWidth: .infinity)
.padding(18)
.meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
}
private var sessionSetupCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text(openChargeSession == nil ? "Session Setup" : "Session Context")
.font(.headline)
ContextInfoButton(
title: openChargeSession == nil ? "Session Setup" : "Session Context",
message: "Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection."
)
Spacer()
Button("Library") {
chargedDeviceLibraryVisibility = true
}
.disabled(openChargeSession != nil)
}
if let selectedChargedDevice {
deviceSummary(selectedChargedDevice)
if openChargeSession == nil {
setupControls(for: selectedChargedDevice)
}
Button("Edit Device") {
editingChargedDevice = selectedChargedDevice
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
if selectedChargedDevice != nil {
Divider()
}
standbyMeasurementSection
}
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
}
private func deviceSummary(_ chargedDevice: ChargedDeviceSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 14) {
ChargedDeviceQRCodeView(
qrIdentifier: chargedDevice.qrIdentifier,
side: 88
)
VStack(alignment: .leading, spacing: 8) {
ChargedDeviceIdentityLabelView(
chargedDevice: chargedDevice,
iconPointSize: 17
)
.font(.headline)
Text(chargedDevice.identityTitle)
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
Text(chargedDevice.chargingStateAvailability.description)
.font(.caption2)
.foregroundColor(.secondary)
Text(chargedDevice.chargingSupportSummary)
.font(.caption2)
.foregroundColor(.secondary)
if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh {
Text("Estimated capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
}
if showsWirelessChargerSection {
Divider()
wirelessChargerSection
}
}
}
private func setupControls(for chargedDevice: ChargedDeviceSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
if requiresExplicitTransportSelection {
VStack(alignment: .leading, spacing: 8) {
Text("Charging Type")
.font(.subheadline.weight(.semibold))
compactSelectionMenu(
title: draftChargingTransportMode?.title ?? "Choose",
options: chargedDevice.supportedChargingModes.map { chargingTransportMode in
CompactSelectionOption(
id: chargingTransportMode.id,
title: chargingTransportMode.title,
isSelected: draftChargingTransportMode == chargingTransportMode,
action: { draftChargingTransportMode = chargingTransportMode }
)
}
)
if draftChargingTransportMode == nil {
Text("Pick the charging type explicitly before starting.")
.font(.caption2)
.foregroundColor(.orange)
}
}
} else if let chargingTransportMode = chargedDevice.supportedChargingModes.first {
Label(
"Charging type: \(chargingTransportMode.title)",
systemImage: chargingTransportMode.symbolName
)
.font(.subheadline.weight(.semibold))
}
if requiresExplicitChargingStateSelection {
VStack(alignment: .leading, spacing: 8) {
Text("Charging Mode")
.font(.subheadline.weight(.semibold))
compactSelectionMenu(
title: draftChargingStateMode?.title ?? "Choose",
options: chargedDevice.supportedChargingStateModes.map { chargingStateMode in
CompactSelectionOption(
id: chargingStateMode.id,
title: chargingStateMode.title,
isSelected: draftChargingStateMode == chargingStateMode,
action: { draftChargingStateMode = chargingStateMode }
)
}
)
if draftChargingStateMode == nil {
Text("Pick whether the device is on or off for this session.")
.font(.caption2)
.foregroundColor(.orange)
}
}
} else if let chargingStateMode = chargedDevice.supportedChargingStateModes.first {
Label(
"Charging mode: \(chargingStateMode.title)",
systemImage: chargingStateMode == .off ? "power.circle" : "power"
)
.font(.subheadline.weight(.semibold))
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Initial Checkpoint")
.font(.subheadline.weight(.semibold))
ContextInfoButton(
title: "Initial Checkpoint",
message: "Use the battery level shown by the device right now when it is known. A known checkpoint improves battery prediction and capacity learning, but the session can also start without one when the level is unavailable."
)
}
compactSelectionMenu(
title: initialCheckpointMode.title,
options: InitialCheckpointMode.allCases.map { mode in
CompactSelectionOption(
id: mode.id,
title: mode.title,
isSelected: initialCheckpointMode == mode,
action: { initialCheckpointMode = mode }
)
}
)
if initialCheckpointMode == .known {
HStack(spacing: 10) {
Button {
adjustInitialCheckpoint(by: -1)
} label: {
Image(systemName: "minus.circle")
.font(.title3)
}
.buttonStyle(.plain)
TextField("Battery %", text: $initialCheckpoint)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 92)
Text("%")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
Button {
adjustInitialCheckpoint(by: 1)
} label: {
Image(systemName: "plus.circle")
.font(.title3)
}
.buttonStyle(.plain)
Spacer()
}
} else {
Text(
initialCheckpointMode == .flat
? "Use Flat when the device does not turn on yet. Predictions and capacity estimates stay off until you record a positive battery level."
: "Start without an initial battery checkpoint only when the level cannot be read reliably, for example on a device without display."
)
.font(.caption2)
.foregroundColor(.orange)
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Start Requirements")
.font(.subheadline.weight(.semibold))
if startRequirements.isEmpty {
Label("Everything needed to start is ready.", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundColor(.green)
} else {
ForEach(startRequirements) { requirement in
Label(requirement.message, systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundColor(.orange)
}
}
}
Button("Start Session") {
startSession()
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
.disabled(!canStartSession)
}
}
private var wirelessChargerSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Wireless Charger")
.font(.subheadline.weight(.semibold))
Spacer()
Button(selectedCharger == nil ? "Select" : "Change") {
chargerLibraryVisibility = true
}
.disabled(openChargeSession != nil)
}
if let selectedCharger {
HStack(alignment: .top, spacing: 12) {
ChargedDeviceQRCodeView(
qrIdentifier: selectedCharger.qrIdentifier,
side: 62
)
VStack(alignment: .leading, spacing: 6) {
ChargedDeviceIdentityLabelView(
chargedDevice: selectedCharger,
iconPointSize: 15
)
.font(.subheadline.weight(.semibold))
if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
.font(.caption)
.foregroundColor(.secondary)
}
if let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps {
Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A")
.font(.caption2)
.foregroundColor(.secondary)
} else {
Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.")
.font(.caption2)
.foregroundColor(.orange)
}
}
}
} else {
Text("Wireless sessions need a selected charger in addition to the charged device.")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var standbyMeasurementSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Charger Standby Power")
.font(.subheadline.weight(.semibold))
Spacer()
Button(selectedCharger == nil ? "Select Charger" : "Change Charger") {
chargerLibraryVisibility = true
}
.disabled(openChargeSession != nil)
}
if let selectedCharger {
HStack(alignment: .top, spacing: 12) {
ChargedDeviceQRCodeView(
qrIdentifier: selectedCharger.qrIdentifier,
side: 62
)
VStack(alignment: .leading, spacing: 6) {
ChargedDeviceIdentityLabelView(
chargedDevice: selectedCharger,
iconPointSize: 15
)
.font(.subheadline.weight(.semibold))
Text(
selectedCharger.latestStandbyPowerMeasurement.map {
"Latest standby: \($0.averagePowerWatts.format(decimalDigits: 3)) W"
} ?? "No standby baseline saved yet."
)
.font(.caption)
.foregroundColor(.secondary)
}
}
NavigationLink(
destination: ChargerStandbyPowerWizardView(
preferredMeterMACAddress: meterMACAddress,
preferredChargerID: selectedCharger.id
)
) {
Label("New Measurement", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
}
.buttonStyle(.plain)
.disabled(openChargeSession != nil)
if openChargeSession != nil {
Text("Stop or pause the active charge session before starting a standby-power run on this meter.")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
NavigationLink(
destination: ChargerStandbyPowerWizardView(
preferredMeterMACAddress: meterMACAddress
)
) {
Label("New Measurement", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
}
.buttonStyle(.plain)
Text("Open the wizard and choose the charger there, or preselect one from Charge Record first.")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
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 metricRows = sessionMetricRows(
for: openChargeSession,
displayedEnergyWh: displayedEnergyWh
)
return VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("Charging Monitor")
.font(.headline)
ContextInfoButton(
title: "Charging Monitor",
message: "The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own."
)
}
ChargeRecordMetricsTableView(
labels: metricRows.map(\.label),
values: metricRows.map(\.value)
)
if openChargeSession.stopThresholdAmps > 0 {
Text("Meter monitor threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
.font(.caption)
.foregroundColor(.secondary)
}
if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
for: openChargeSession,
effectiveEnergyWhOverride: displayedEnergyWh
) {
VStack(alignment: .leading, spacing: 4) {
Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
.font(.caption.weight(.semibold))
Text(
"Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
)
.font(.caption2)
.foregroundColor(.secondary)
}
}
if let sessionWarning = sessionWarning(for: openChargeSession) {
Text(sessionWarning)
.font(.caption)
.foregroundColor(.orange)
}
if openChargeSession.isPaused {
Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.")
.font(.caption)
.foregroundColor(.secondary)
}
if openChargeSession.requiresCompletionConfirmation {
completionConfirmationCard(openChargeSession)
}
if let targetBatteryPercent = openChargeSession.targetBatteryPercent {
Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
.font(.caption.weight(.semibold))
} else {
Text("No target battery notification configured.")
.font(.caption)
.foregroundColor(.secondary)
}
BatteryCheckpointSectionView(
sessionID: openChargeSession.id,
checkpoints: openChargeSession.checkpoints,
message: "The checkpoint is stored on the active charge session and later 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
}
)
Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
targetNotificationEditorVisibility = true
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
if openChargeSession.targetBatteryPercent != nil {
Button("Clear Target Notification") {
_ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
.buttonStyle(.plain)
}
if openChargeSession.status == .active {
Button("Pause Session") {
_ = 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 Session") {
_ = 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("Stop Session") {
pendingStopRequest = ChargeSessionStopRequest(
sessionID: openChargeSession.id,
title: "Stop Session",
confirmTitle: "Stop",
explanation: "Record the final battery checkpoint before closing this session."
)
}
.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)
}
private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Completion Needs Confirmation")
.font(.subheadline.weight(.semibold))
if let contradictionPercent = openChargeSession.completionContradictionPercent {
Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Current dropped to the learned stop threshold, but the battery prediction does not look like a normal finish yet.")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Finish Session With Final Checkpoint") {
pendingStopRequest = ChargeSessionStopRequest(
sessionID: openChargeSession.id,
title: "Finish Session",
confirmTitle: "Finish",
explanation: "Add the final checkpoint before confirming the stop."
)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.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, 10)
.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 sessionChartCard(
timeRange: ClosedRange<Date>,
session: ChargeSessionSummary
) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("Session Chart")
.font(.headline)
ContextInfoButton(
title: "Session Chart",
message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
)
}
GeometryReader { geometry in
let chartWidth = max(geometry.size.width, 1)
let compactChartLayout = chartWidth < 760
let chartHeight = compactChartLayout ? 290.0 : 350.0
MeasurementChartView(
compactLayout: compactChartLayout,
availableSize: CGSize(width: chartWidth, height: chartHeight),
timeRange: timeRange,
showsRangeSelector: false,
rebasesEnergyToVisibleRangeStart: true
)
.environmentObject(usbMeter.measurements)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(height: 350)
}
.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)
}
private var showsWirelessChargerSection: Bool {
let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
return transportMode == .wireless
}
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(max(session.effectiveDuration, 0))))
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.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.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 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: 180, alignment: .leading)
.meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
}
.buttonStyle(.plain)
}
}
struct ChargeSessionCompletionSheetView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
let sessionID: UUID
let title: String
let confirmTitle: String
let explanation: String
@State private var batteryPercent = ""
var body: some View {
NavigationView {
Form {
Section(
header: ContextInfoHeader(
title: "Final Checkpoint",
message: explanation
)
) {
TextField("Battery %", text: $batteryPercent)
.keyboardType(.decimalPad)
}
Section {
if let sessionWarning {
Text(sessionWarning)
.font(.footnote)
.foregroundColor(.orange)
} else if (parsedBatteryPercent ?? 0) >= 99.5 {
Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(confirmTitle) {
guard let batteryPercent = parsedBatteryPercent else {
return
}
if appData.stopChargeSession(
sessionID: sessionID,
finalBatteryPercent: batteryPercent
) {
dismiss()
}
}
.disabled(parsedBatteryPercent == nil)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private var parsedBatteryPercent: Double? {
let normalized = batteryPercent
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
guard let value = Double(normalized), value >= 0, value <= 100 else {
return nil
}
return value
}
private var sessionWarning: String? {
guard let session = appData.chargedDevices
.flatMap(\.sessions)
.first(where: { $0.id == sessionID }),
session.chargingTransportMode == .wireless,
let chargerID = session.chargerID,
let charger = appData.chargedDeviceSummary(id: chargerID),
charger.chargerIdleCurrentAmps == nil else {
return nil
}
return "This charger has no idle-current measurement, so the final checkpoint will stop the session but will not learn a wireless stop threshold yet."
}
}
private struct ChargeSessionStopRequest: Identifiable {
let sessionID: UUID
let title: String
let confirmTitle: String
let explanation: String
var id: UUID {
sessionID
}
}