// // ChargedDeviceActiveSessionView.swift // USB Meter // // Created by Codex on 22/04/2026. // import SwiftUI struct ChargedDeviceActiveSessionView: View { @EnvironmentObject private var appData: AppData @State private var targetNotificationEditorVisibility = false @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? @State private var pendingSessionStopRequest: ActiveDeviceSessionStopRequest? let chargedDeviceID: UUID private var chargedDevice: ChargedDeviceSummary? { appData.chargedDeviceSummary(id: chargedDeviceID) } private var activeSession: ChargeSessionSummary? { chargedDevice?.activeSession } var body: some View { Group { if let chargedDevice, let activeSession { ScrollView { VStack(spacing: 16) { activeSessionCard(activeSession, chargedDevice: chargedDevice) if !activeSession.displayedAggregatedSamples.isEmpty { storedCurveCard(activeSession) } } .padding() } .background( LinearGradient( colors: [statusTint(for: activeSession).opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Current Session") } else { Text("There is no open session for this device.") .foregroundColor(.secondary) .navigationTitle("Current Session") } } .sheet(isPresented: $targetNotificationEditorVisibility) { if let activeSession { ActiveSessionTargetNotificationEditorSheetView( sessionID: activeSession.id, initialTargetPercent: activeSession.targetBatteryPercent ) .environmentObject(appData) } } .sheet(item: $pendingSessionStopRequest) { 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")) { _ = appData.deleteBatteryCheckpoint( checkpointID: checkpoint.id, for: checkpoint.sessionID ) }, secondaryButton: .cancel() ) } } private func activeSessionCard( _ activeSession: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { MeterInfoCardView(title: "Open Session", tint: statusTint(for: activeSession)) { MeterInfoRowView(label: "Started", value: activeSession.startedAt.format()) MeterInfoRowView(label: "Status", value: activeSession.status.title) MeterInfoRowView(label: "Duration", value: sessionDurationText(activeSession)) MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title) MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title) MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh") if activeSession.chargingTransportMode == .wireless, let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh, abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 { MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh") } MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession)) MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title) if chargedDevice.isCharger == false, let chargerID = activeSession.chargerID, let charger = appData.chargedDeviceSummary(id: chargerID) { MeterInfoRowView(label: "Wireless Charger", value: charger.name) } if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps { MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A") } if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts { MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W") } if activeSession.chargingTransportMode == .wired, let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts { MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V") } if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts { MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V") } if let targetBatteryPercent = activeSession.targetBatteryPercent { MeterInfoRowView( label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%" ) } if let sessionWarning = sessionWarning(for: activeSession) { Text(sessionWarning) .font(.caption2) .foregroundColor(.orange) } if let wirelessSessionHint = wirelessSessionHint(for: activeSession) { Text(wirelessSessionHint) .font(.caption2) .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) } if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) { MeterInfoRowView( 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) } BatteryCheckpointSectionView( sessionID: activeSession.id, checkpoints: activeSession.checkpoints, message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.", canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id), requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id), effectiveEnergyWhOverride: nil, measuredChargeAhOverride: nil, onDelete: { checkpoint in pendingCheckpointDeletion = checkpoint } ) Button(activeSession.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 activeSession.targetBatteryPercent != nil { Button("Clear Target Notification") { _ = appData.setTargetBatteryPercent(nil, for: activeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) .buttonStyle(.plain) } if activeSession.status == .active { Button("Pause Session") { _ = appData.pauseChargeSession(sessionID: activeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } else if activeSession.status == .paused { Button("Resume Session") { _ = appData.resumeChargeSession(sessionID: activeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) Text("Paused sessions close automatically after 10 minutes.") .font(.caption2) .foregroundColor(.secondary) } Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") { pendingSessionStopRequest = ActiveDeviceSessionStopRequest( sessionID: activeSession.id, title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session", confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop", explanation: "Add 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) if activeSession.requiresCompletionConfirmation { Divider() if let contradictionPercent = activeSession.completionContradictionPercent { Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.") .font(.caption2) .foregroundColor(.secondary) } Button("Keep Monitoring") { _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } } } private func storedCurveCard(_ session: ChargeSessionSummary) -> some View { let displayedSamples = session.displayedAggregatedSamples let currentSeries = storedSeriesSnapshot( from: displayedSamples, minimumYSpan: 0.15 ) { $0.averageCurrentAmps } let energySeries = storedSeriesSnapshot( from: displayedSamples, minimumYSpan: 0.2 ) { $0.measuredEnergyWh } return VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text("Stored Session Curve") .font(.headline) ContextInfoButton( title: "Stored Session Curve", message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress." ) } Text("Open session, persisted as aggregated samples.") .font(.caption) .foregroundColor(.secondary) } Spacer() Text("\(displayedSamples.count) points") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } if let currentSeries { storedSeriesChart( title: "Current", unit: "A", strokeColor: .blue, snapshot: currentSeries ) } if let energySeries { storedSeriesChart( title: "Energy", unit: "Wh", strokeColor: .teal, areaChart: true, snapshot: energySeries ) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) } private func storedSeriesSnapshot( from samples: [ChargeSessionSampleSummary], minimumYSpan: Double, value: (ChargeSessionSampleSummary) -> Double ) -> ActiveSessionSeriesSnapshot? { let sortedSamples = samples.sorted { lhs, rhs in if lhs.bucketIndex != rhs.bucketIndex { return lhs.bucketIndex < rhs.bucketIndex } return lhs.timestamp < rhs.timestamp } guard let firstSample = sortedSamples.first, let lastSample = sortedSamples.last else { return nil } let points = sortedSamples.enumerated().map { index, sample in Measurements.Measurement.Point( id: index, timestamp: sample.timestamp, value: value(sample), kind: .sample ) } let minimumValue = points.map(\.value).min() ?? 0 let maximumValue = points.map(\.value).max() ?? minimumValue let context = ChartContext() context.setBounds( xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970), xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)), yMin: CGFloat(minimumValue), yMax: CGFloat(maximumValue) ) context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan))) return ActiveSessionSeriesSnapshot( points: points, context: context, minimumValue: minimumValue, maximumValue: maximumValue ) } private func storedSeriesChart( title: String, unit: String, strokeColor: Color, areaChart: Bool = false, snapshot: ActiveSessionSeriesSnapshot ) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text(title) .font(.subheadline.weight(.semibold)) Spacer() Text( "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)" ) .font(.caption2) .foregroundColor(.secondary) } TimeSeriesChart( points: snapshot.points, context: snapshot.context, areaChart: areaChart, strokeColor: strokeColor ) .frame(height: 118) .padding(.horizontal, 6) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 16) .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06)) ) HStack { Text(snapshot.startLabel) Spacer() Text(snapshot.endLabel) } .font(.caption2) .foregroundColor(.secondary) } } private func sessionDurationText(_ session: ChargeSessionSummary) -> String { let formatter = DateComponentsFormatter() let effectiveDuration = max(session.effectiveDuration, 0) formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropAll return formatter.string(from: effectiveDuration) ?? "0m" } 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 sessionWarning(for session: ChargeSessionSummary) -> String? { guard 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 wireless stop threshold cannot be learned or auto-applied for this session." } 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 } } } private struct ActiveSessionSeriesSnapshot { let points: [Measurements.Measurement.Point] let context: ChartContext let minimumValue: Double let maximumValue: Double var lastValue: Double { points.last?.value ?? 0 } var startLabel: String { guard let firstTimestamp = points.first?.timestamp else { return "" } return firstTimestamp.formatted(date: .omitted, time: .shortened) } var endLabel: String { guard let lastTimestamp = points.last?.timestamp else { return "" } return lastTimestamp.formatted(date: .omitted, time: .shortened) } } private struct ActiveSessionTargetNotificationEditorSheetView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var appData: AppData let sessionID: UUID let initialTargetPercent: Double? @State private var targetPercent: Double init(sessionID: UUID, initialTargetPercent: Double?) { self.sessionID = sessionID self.initialTargetPercent = initialTargetPercent _targetPercent = State(initialValue: initialTargetPercent ?? 80) } var body: some View { NavigationView { Form { Section( header: ContextInfoHeader( title: "Target Level", message: "A local notification will be generated on synced devices when the estimated battery level reaches this target." ) ) { VStack(alignment: .leading, spacing: 12) { Text("\(targetPercent.format(decimalDigits: 0))%") .font(.title3.weight(.bold)) Slider(value: $targetPercent, in: 20...100, step: 1) } } } .navigationTitle("Battery Target") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { if appData.setTargetBatteryPercent(targetPercent, for: sessionID) { dismiss() } } } } } .navigationViewStyle(StackNavigationViewStyle()) } } private struct ActiveDeviceSessionStopRequest: Identifiable { let sessionID: UUID let title: String let confirmTitle: String let explanation: String var id: UUID { sessionID } }