1 contributor
//
// 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
}
}