// // 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 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 { activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) } if !chargedDevice.capacityHistory.isEmpty { capacityEvolutionCard(chargedDevice) } if !chargedDevice.typicalCurve.isEmpty { typicalCurveCard(chargedDevice) } if !closedSessions(for: chargedDevice).isEmpty { sessionHistorySummaryCard(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) } } } .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 activeSessionSummaryCard( _ activeSession: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { NavigationLink( destination: ChargedDeviceActiveSessionView(chargedDeviceID: chargedDevice.id) ) { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text("Current Session") .font(.headline) .foregroundColor(.primary) Text(activeSession.status.title) .font(.caption.weight(.semibold)) .foregroundColor(statusTint(for: activeSession)) } Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) { activeSessionMetricCell( label: "Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal ) activeSessionMetricCell( label: "Duration", value: sessionDurationText(activeSession), tint: .orange ) if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts { activeSessionMetricCell( label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue ) } if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) { activeSessionMetricCell( label: "Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%", tint: .green ) } else if let targetBatteryPercent = activeSession.targetBatteryPercent { activeSessionMetricCell( label: "Target", value: "\(targetBatteryPercent.format(decimalDigits: 0))%", tint: .indigo ) } } Text("Started \(activeSession.startedAt.format())") .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.plain) .padding(18) .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) } private var activeSessionSummaryColumns: [GridItem] { [ GridItem(.flexible(minimum: 92), spacing: 8), GridItem(.flexible(minimum: 92), spacing: 8) ] } private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption2) .foregroundColor(.secondary) Text(value) .font(.footnote.weight(.semibold)) .foregroundColor(.primary) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) } 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 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 sessionHistorySummaryCard(_ chargedDevice: ChargedDeviceSummary) -> some View { let sessions = closedSessions(for: chargedDevice) let latestSession = sessions.first let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh } return MeterInfoCardView(title: "Session History", tint: .teal) { MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)") MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh") if let latestSession { MeterInfoRowView(label: "Latest", value: latestSession.startedAt.format()) } NavigationLink( destination: ChargedDeviceSessionsView(chargedDeviceID: chargedDevice.id) ) { Label("Manage Sessions", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .teal, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) } .buttonStyle(.plain) } } private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] { chargedDevice.sessions.filter { !$0.status.isOpen } } 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 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 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 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 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." } }