// // ChargedDeviceSessionDetailView.swift // USB Meter // // Created by Codex on 22/04/2026. // import SwiftUI struct ChargedDeviceSessionDetailView: View { @EnvironmentObject private var appData: AppData @State private var pendingSessionDeletion: ChargeSessionSummary? @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? let chargedDeviceID: UUID let sessionID: UUID private var chargedDevice: ChargedDeviceSummary? { appData.chargedDeviceSummary(id: chargedDeviceID) } private var session: ChargeSessionSummary? { chargedDevice?.sessions.first(where: { $0.id == sessionID }) } var body: some View { Group { if let chargedDevice, let session { ScrollView { VStack(spacing: 16) { overviewCard(session, chargedDevice: chargedDevice) energyCard(session, chargedDevice: chargedDevice) observedMetricsCard(session, chargedDevice: chargedDevice) batteryCard(session) if !session.displayedAggregatedSamples.isEmpty { storedCurveCard(session) } managementCard(session) } .padding() } .background( LinearGradient( colors: [statusTint(for: session).opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Session Details") } else { Text("This session is no longer available.") .foregroundColor(.secondary) .navigationTitle("Session") } } .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() ) } } private func overviewCard( _ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) { MeterInfoRowView(label: "Device", value: chargedDevice.name) MeterInfoRowView(label: "Status", value: session.status.title) MeterInfoRowView(label: "Started", value: session.startedAt.format()) if let endedAt = session.endedAt { MeterInfoRowView(label: "Ended", value: endedAt.format()) } MeterInfoRowView(label: "Duration", value: sessionDurationText(session)) MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title) MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title) MeterInfoRowView(label: "Source", value: session.sourceMode.title) MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session)) if session.isTrimmed { MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format()) MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format()) } if let meterName = session.meterName { MeterInfoRowView(label: "Meter", value: meterName) } else if let meterMACAddress = session.meterMACAddress { MeterInfoRowView(label: "Meter", value: meterMACAddress) } if let meterModel = session.meterModel { MeterInfoRowView(label: "Meter Model", value: meterModel) } } } private func energyCard( _ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { MeterInfoCardView(title: "Energy", tint: .teal) { MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh") MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah") if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 { MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") } if let capacityEstimateWh = session.capacityEstimateWh { MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") } if 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) } } } private func observedMetricsCard( _ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary ) -> some View { MeterInfoCardView(title: "Observed Metrics", tint: .blue) { if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps { MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A") } 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 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 let completionCurrentAmps = session.completionCurrentAmps { MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A") } if session.selectedDataGroup != nil { MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)") } } } private func batteryCard(_ session: ChargeSessionSummary) -> some View { MeterInfoCardView(title: "Battery", tint: .orange) { if let startBatteryPercent = session.startBatteryPercent { MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%") } if let endBatteryPercent = session.endBatteryPercent { MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%") } if let batteryDeltaPercent = session.batteryDeltaPercent { MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%") } BatteryCheckpointSectionView( sessionID: session.id, checkpoints: session.checkpoints, message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", canAddCheckpoint: false, requirementMessage: nil, effectiveEnergyWhOverride: nil, measuredChargeAhOverride: nil, onDelete: { checkpoint in pendingCheckpointDeletion = checkpoint } ) } } private func managementCard(_ session: ChargeSessionSummary) -> some View { MeterInfoCardView(title: "Administration", tint: .red) { Button(role: .destructive) { pendingSessionDeletion = session } label: { Label("Delete Session", systemImage: "trash") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, 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) { Text("Session Curve") .font(.headline) Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.") .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 ) -> StoredSessionSeriesSnapshot? { 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 StoredSessionSeriesSnapshot( points: points, context: context, minimumValue: minimumValue, maximumValue: maximumValue ) } private func storedSeriesChart( title: String, unit: String, strokeColor: Color, areaChart: Bool = false, snapshot: StoredSessionSeriesSnapshot ) -> 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 session.stopThresholdAmps > 0 { return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" } return "Learning" } 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 StoredSessionSeriesSnapshot { 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) } }