1 contributor
//
// ChargedDeviceDetailView.swift
// USB Meter
//
// Created by Codex on 10/04/2026.
//
import SwiftUI
struct ChargedDeviceDetailView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
@State private var editorVisibility = false
@State private var targetNotificationEditorVisibility = false
@State private var pendingSessionDeletion: ChargeSessionSummary?
@State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
@State private var pendingSessionStopRequest: DeviceSessionStopRequest?
@State private var deleteConfirmationVisibility = false
let chargedDeviceID: UUID
var body: some View {
Group {
if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
ScrollView {
VStack(spacing: 18) {
headerCard(chargedDevice)
insightsCard(chargedDevice)
if chargedDevice.isCharger {
standbyPowerCard(chargedDevice)
}
if let activeSession = chargedDevice.activeSession {
activeSessionCard(activeSession, chargedDevice: chargedDevice)
}
if let curveSession = preferredStoredCurveSession(for: chargedDevice) {
storedCurveCard(curveSession)
}
if !chargedDevice.capacityHistory.isEmpty {
capacityEvolutionCard(chargedDevice)
}
if !chargedDevice.typicalCurve.isEmpty {
typicalCurveCard(chargedDevice)
}
if !chargedDevice.sessions.isEmpty {
sessionsCard(chargedDevice)
}
}
.padding()
}
.background(
LinearGradient(
colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle(chargedDevice.name)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Edit") {
editorVisibility = true
}
Button(role: .destructive) {
deleteConfirmationVisibility = true
} label: {
Image(systemName: "trash")
}
}
}
} else {
Text("This device is no longer available.")
.foregroundColor(.secondary)
.navigationTitle("Device")
}
}
.sheet(isPresented: $editorVisibility) {
if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
if chargedDevice.isCharger {
ChargerEditorSheetView(
appData: appData,
chargedDevice: chargedDevice
)
} else {
ChargedDeviceEditorSheetView(
meterMACAddress: nil,
chargedDevice: chargedDevice
)
.environmentObject(appData)
}
}
}
.sheet(isPresented: $targetNotificationEditorVisibility) {
if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
ChargedDeviceTargetNotificationEditorSheetView(
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: $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: $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()
)
}
.confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
Button("Delete", role: .destructive) {
if appData.deleteChargedDevice(id: chargedDeviceID) {
dismiss()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text(deletionMessage)
}
}
private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
HStack(alignment: .top, spacing: 18) {
ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
VStack(alignment: .leading, spacing: 10) {
ChargedDeviceIdentityLabelView(
chargedDevice: chargedDevice,
iconPointSize: 22
)
.font(.title3.weight(.bold))
Text(chargedDevice.identityTitle)
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
Text("Default meter: \(meterMAC)")
.font(.caption)
.foregroundColor(.secondary)
}
Text(chargedDevice.qrIdentifier)
.font(.caption2.monospaced())
.foregroundColor(.secondary)
.textSelection(.enabled)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
}
private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
if chargedDevice.isCharger {
chargerInsights(chargedDevice)
} else {
deviceInsights(chargedDevice)
}
if let notes = chargedDevice.notes, !notes.isEmpty {
Divider()
Text(notes)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@ViewBuilder
private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
if chargedDevice.hasMultipleChargingStateModes {
MeterInfoRowView(
label: "Charge Modes",
value: chargedDevice.chargingStateAvailability.title
)
}
if chargedDevice.hasMultipleChargingTransports {
MeterInfoRowView(
label: "Charging Support",
value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
)
}
if chargedDevice.showsWirelessProfileDetails {
MeterInfoRowView(
label: "Wireless Profile",
value: chargedDevice.wirelessChargingProfile.title
)
}
ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
MeterInfoRowView(
label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind),
value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
)
}
MeterInfoRowView(
label: "Estimated Capacity",
value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
)
if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
if chargedDevice.hasMultipleChargingTransports {
MeterInfoRowView(
label: "Wired Capacity",
value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
)
}
}
if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
if chargedDevice.hasMultipleChargingTransports {
MeterInfoRowView(
label: "Wireless Capacity",
value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
)
}
}
if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor,
chargedDevice.showsWirelessProfileDetails {
MeterInfoRowView(
label: "Wireless Efficiency",
value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
)
}
MeterInfoRowView(
label: "Charge Sessions",
value: "\(chargedDevice.sessionCount)"
)
}
@ViewBuilder
private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
if let chargerType = chargedDevice.chargerType {
MeterInfoRowView(
label: "Type",
value: chargerType.title
)
}
if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
MeterInfoRowView(
label: "Observed Voltages",
value: chargedDevice.chargerObservedVoltageSelections
.map { "\($0.format(decimalDigits: 1)) V" }
.joined(separator: ", ")
)
}
if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
MeterInfoRowView(
label: "Idle Current",
value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
)
}
if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
MeterInfoRowView(
label: "Efficiency",
value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
)
}
if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
MeterInfoRowView(
label: "Max Power",
value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
)
}
if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
MeterInfoRowView(
label: "Standby Power",
value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W"
)
MeterInfoRowView(
label: "Standby Projection",
value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year"
)
}
MeterInfoRowView(
label: "Wireless Sessions",
value: "\(chargedDevice.sessionCount)"
)
if chargedDevice.chargerIdleCurrentAmps == nil {
Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
.font(.caption2)
.foregroundColor(.orange)
}
}
private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement
return MeterInfoCardView(
title: "Standby Power",
tint: .orange
) {
if standbyMeasurementMeters.isEmpty {
Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
NavigationLink(
destination: ChargerStandbyPowerWizardView(
preferredChargerID: chargedDevice.id,
locksChargerSelection: true
)
) {
Label("New Measurement", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
}
.buttonStyle(.plain)
}
if let latestMeasurement {
Divider()
NavigationLink(
destination: ChargerStandbyPowerMeasurementDetailView(
chargerID: chargedDevice.id,
measurementID: latestMeasurement.id
)
) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Latest Measurement")
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Spacer()
Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
.font(.subheadline.weight(.bold))
.foregroundColor(.primary)
.monospacedDigit()
}
Text(
"\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
}
if chargedDevice.standbyPowerMeasurements.isEmpty == false {
Divider()
NavigationLink(
destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
) {
Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
.font(.subheadline.weight(.semibold))
.foregroundColor(.blue)
}
.buttonStyle(.plain)
}
}
}
private func activeSessionCard(
_ activeSession: ChargeSessionSummary,
chargedDevice: ChargedDeviceSummary
) -> some View {
MeterInfoCardView(title: "Open Session", tint: .green) {
MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
MeterInfoRowView(label: "Status", value: activeSession.status.title)
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 = DeviceSessionStopRequest(
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 capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Capacity Evolution")
.font(.headline)
ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
HStack {
Text(point.timestamp.format())
.font(.caption)
.foregroundColor(.secondary)
Spacer()
if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
Text(point.chargingTransportMode.title)
.font(.caption2)
.foregroundColor(.secondary)
Text("•")
.foregroundColor(.secondary)
}
Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
.font(.footnote.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
}
private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
return activeSession
}
return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
}
private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
let currentSeries = storedSeriesSnapshot(
from: session.aggregatedSamples,
minimumYSpan: 0.15
) { $0.averageCurrentAmps }
let energySeries = storedSeriesSnapshot(
from: session.aggregatedSamples,
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(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text("\(session.aggregatedSamples.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 typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Typical Charge Curve")
.font(.headline)
ForEach(chargedDevice.typicalCurve) { point in
HStack {
Text("\(point.percentBin)%")
.font(.footnote.weight(.semibold))
Spacer()
Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
.font(.caption.weight(.semibold))
Text("•")
.foregroundColor(.secondary)
Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
}
private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("Charge Sessions")
.font(.headline)
ContextInfoButton(
title: "Charge Sessions",
message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
)
}
ForEach(chargedDevice.sessions, id: \.id) { session in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(session.startedAt.format())
.font(.caption.weight(.semibold))
Text(session.status.title)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(statusTint(for: session).opacity(0.16))
)
Spacer()
Button {
pendingSessionDeletion = session
} label: {
Image(systemName: "trash")
.font(.caption.weight(.semibold))
.foregroundColor(.red)
.padding(8)
.background(
Circle()
.fill(Color.red.opacity(0.10))
)
}
.buttonStyle(.plain)
}
Text(sessionSummaryLine(session))
.font(.caption2)
.foregroundColor(.secondary)
MeterInfoRowView(
label: "Duration",
value: sessionDurationText(session)
)
MeterInfoRowView(
label: "Energy",
value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
)
if session.chargingTransportMode == .wireless,
let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
MeterInfoRowView(
label: "Charger Energy",
value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
)
}
if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
MeterInfoRowView(
label: "Max Current",
value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
)
}
if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
MeterInfoRowView(
label: "Max Power",
value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
)
}
if session.chargingTransportMode == .wired,
let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
MeterInfoRowView(
label: "Max Voltage",
value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
)
}
if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
MeterInfoRowView(
label: "Selected Voltage",
value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
)
}
if chargedDevice.isCharger == false,
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)
}
}
.padding(14)
.meterCard(
tint: statusTint(for: session),
fillOpacity: 0.10,
strokeOpacity: 0.16,
cornerRadius: 16
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
}
private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
var components: [String] = []
let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID)
if let batteryDeltaPercent = session.batteryDeltaPercent {
components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
}
if let capacityEstimateWh = session.capacityEstimateWh {
components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
}
if chargedDevice?.shouldShowChargingTransport(session.chargingTransportMode) != false {
components.append(session.chargingTransportMode.title)
}
if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
components.append(session.chargingStateMode.title)
}
components.append(session.sourceMode.title)
return components.joined(separator: " • ")
}
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 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 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 func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
switch chargedDevice.deviceClass {
case .iphone:
return .blue
case .watch:
return .green
case .powerbank:
return .orange
case .charger:
return .pink
case .other:
return .secondary
}
}
private func standbyEnergyLabel(_ wattHours: Double) -> String {
if wattHours >= 1000 {
return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
}
return "\(wattHours.format(decimalDigits: 2)) Wh"
}
private var standbyMeasurementMeters: [AppData.MeterSummary] {
appData.meterSummaries.filter { $0.meter != nil }
}
private func completionCurrentDescription(
for chargedDevice: ChargedDeviceSummary,
sessionKind: ChargeSessionKind
) -> String {
if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
abs(configuredCurrent - learnedCurrent) >= 0.01 {
return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
}
return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
}
if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
}
return "Learning"
}
private func completionCurrentLabel(
for chargedDevice: ChargedDeviceSummary,
sessionKind: ChargeSessionKind
) -> String {
let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
switch (showsTransport, showsState) {
case (true, true):
return "\(sessionKind.shortTitle) Stop Current"
case (true, false):
return "\(sessionKind.chargingTransportMode.title) Stop Current"
case (false, true):
return "\(sessionKind.chargingStateMode.title) Stop Current"
case (false, false):
return "Stop Current"
}
}
private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
chargedDevice.supportedChargingStateModes.map { chargingStateMode in
ChargeSessionKind(
chargingTransportMode: chargingTransportMode,
chargingStateMode: chargingStateMode
)
}
}
}
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 var deletionTitle: String {
appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
}
private var deletionMessage: String {
if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
return "This removes the charger from the library and unlinks it from wireless sessions that used it."
}
return "This removes the device and its stored charging history from the library."
}
private func storedSeriesSnapshot(
from samples: [ChargeSessionSampleSummary],
minimumYSpan: Double,
value: (ChargeSessionSampleSummary) -> Double
) -> StoredSeriesSnapshot? {
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 StoredSeriesSnapshot(
points: points,
context: context,
minimumValue: minimumValue,
maximumValue: maximumValue
)
}
private func storedSeriesChart(
title: String,
unit: String,
strokeColor: Color,
areaChart: Bool = false,
snapshot: StoredSeriesSnapshot
) -> 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)
}
Chart(
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 struct StoredSeriesSnapshot {
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 ChargedDeviceTargetNotificationEditorSheetView: 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 DeviceSessionStopRequest: Identifiable {
let sessionID: UUID
let title: String
let confirmTitle: String
let explanation: String
var id: UUID {
sessionID
}
}