// // 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" } } } @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: 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) } } else { overviewCard(session, chargedDevice: chargedDevice) batteryCard(session, chargedDevice: chargedDevice) if shouldShowSessionChart(session) { chartCard(session) } 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) -> some View { ChargeSessionChartCardView( session: session, monitoringMeter: liveMonitoringMeter, 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 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 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 { let start = session.startedAt let end = max(session.endedAt ?? session.lastObservedAt, start) return start...end } private var fixedTimeRange: ClosedRange? { 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, 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() } } 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 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) { onSetTrim(normalizedStart(range.lowerBound), session.trimEnd) } private func requestActiveEndTrim(_ range: ClosedRange) { let start = session.trimStart ?? normalizedStart(range.lowerBound) let end = normalizedEnd(range.upperBound) onStopWithTrim(start, end) } private func applyClosedTrim(_ range: ClosedRange) { 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) } }