// // 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 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(session.isTrimmed ? "Showing the saved trim window at aggregated resolution." : (session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")) .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 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) } if session.isTrimmed { components.append("Trimmed") } 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) } 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 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 } }