// // MeterChargeRecordTabView.swift // USB Meter // import SwiftUI struct MeterChargeRecordTabView: View { @EnvironmentObject private var appData: AppData @EnvironmentObject private var usbMeter: Meter @State private var chargedDeviceLibraryVisibility = false @State private var chargerLibraryVisibility = false @State private var checkpointEditorVisibility = false @State private var editingChargedDevice: ChargedDeviceSummary? @State private var targetNotificationEditorVisibility = false var body: some View { ScrollView { VStack(spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Charge Record") .font(.system(.title3, design: .rounded).weight(.bold)) Spacer() Text(usbMeter.chargeRecordStatusText) .font(.caption.weight(.bold)) .foregroundColor(usbMeter.chargeRecordStatusColor) .padding(.horizontal, 10) .padding(.vertical, 6) .meterCard( tint: usbMeter.chargeRecordStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999 ) } Text("App-side charge accumulation based on the stop-threshold workflow.") .font(.footnote) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding(18) .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) chargedDeviceSection if let activeChargeSession { chargeMonitorSection(activeChargeSession) } ChargeRecordMetricsTableView( labels: ["Capacity", "Energy", "Duration", "Stop Threshold"], values: [ "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah", "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh", usbMeter.chargeRecordDurationDescription, "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A" ] ) .padding(18) .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20) if usbMeter.chargeRecordTimeRange != nil { VStack(alignment: .leading, spacing: 12) { HStack { Text("Charge Curve") .font(.headline) Spacer() Button("Reset Graph") { usbMeter.resetChargeRecordGraph() } .foregroundColor(.red) } MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange) .environmentObject(usbMeter.measurements) .frame(minHeight: 220) Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) } VStack(alignment: .leading, spacing: 12) { Text("Stop Threshold") .font(.headline) Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01) Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.") .font(.footnote) .foregroundColor(.secondary) Button("Reset") { usbMeter.resetChargeRecord() } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } .padding(18) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 { VStack(alignment: .leading, spacing: 12) { Text("Meter Totals") .font(.headline) ChargeRecordMetricsTableView( labels: ["Capacity", "Energy", "Duration", "Meter Threshold"], values: [ "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah", "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh", usbMeter.recordingDurationDescription, usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only" ] ) Text("These values are reported by the meter for the active data group.") .font(.footnote) .foregroundColor(.secondary) if usbMeter.supportsDataGroupCommands { Button("Reset Active Group") { usbMeter.clear() } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } } .padding(18) .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) } } .padding() } .background( LinearGradient( colors: [.pink.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .sheet(isPresented: $chargedDeviceLibraryVisibility) { ChargedDeviceLibrarySheetView( visibility: $chargedDeviceLibraryVisibility, meterMACAddress: usbMeter.btSerial.macAddress.description, meterTint: usbMeter.color, mode: .device ) .environmentObject(appData) } .sheet(isPresented: $chargerLibraryVisibility) { ChargedDeviceLibrarySheetView( visibility: $chargerLibraryVisibility, meterMACAddress: usbMeter.btSerial.macAddress.description, meterTint: usbMeter.color, mode: .charger ) .environmentObject(appData) } .sheet(isPresented: $checkpointEditorVisibility) { BatteryCheckpointEditorSheetView() .environmentObject(appData) .environmentObject(usbMeter) } .sheet(item: $editingChargedDevice) { chargedDevice in ChargedDeviceEditorSheetView( meterMACAddress: nil, chargedDevice: chargedDevice ) .environmentObject(appData) } .sheet(isPresented: $targetNotificationEditorVisibility) { if let activeChargeSession { BatteryTargetNotificationEditorSheetView( sessionID: activeChargeSession.id, initialTargetPercent: activeChargeSession.targetBatteryPercent ) .environmentObject(appData) } } } private var selectedChargedDevice: ChargedDeviceSummary? { appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description) } private var activeChargeSession: ChargeSessionSummary? { appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description) } private var selectedCharger: ChargedDeviceSummary? { appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description) } private var chargedDeviceSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Device") .font(.headline) Spacer() Button("Library") { chargedDeviceLibraryVisibility = true } } if let selectedChargedDevice { HStack(alignment: .top, spacing: 14) { ChargedDeviceQRCodeView( qrIdentifier: selectedChargedDevice.qrIdentifier, side: 88 ) VStack(alignment: .leading, spacing: 8) { Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName) .font(.headline) Text(selectedChargedDevice.deviceClass.title) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully") .font(.caption2) .foregroundColor(.secondary) if selectedChargedDevice.supportedChargingModes.count == 1 { Label( "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)", systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName ) .font(.caption2) .foregroundColor(.secondary) } else { Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) { ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName) .tag(chargingTransportMode) } } .pickerStyle(.segmented) } if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) { Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh") .font(.caption) .foregroundColor(.secondary) } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh { Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh") .font(.caption) .foregroundColor(.secondary) } if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) { Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A") .font(.caption2) .foregroundColor(.secondary) } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps { Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A") .font(.caption2) .foregroundColor(.secondary) } } Spacer(minLength: 0) } if shouldShowWirelessChargerSection(for: selectedChargedDevice) { Divider() VStack(alignment: .leading, spacing: 10) { HStack { Text("Wireless Charger") .font(.subheadline.weight(.semibold)) Spacer() Button(selectedCharger == nil ? "Select" : "Change") { chargerLibraryVisibility = true } } if let selectedCharger { HStack(alignment: .top, spacing: 12) { ChargedDeviceQRCodeView( qrIdentifier: selectedCharger.qrIdentifier, side: 62 ) VStack(alignment: .leading, spacing: 6) { Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName) .font(.subheadline.weight(.semibold)) if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts { Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W") .font(.caption) .foregroundColor(.secondary) } if !selectedCharger.chargerObservedVoltageSelections.isEmpty { Text( "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections .map { "\($0.format(decimalDigits: 1)) V" } .joined(separator: ", ") ) .font(.caption2) .foregroundColor(.secondary) } } } } else { Text("Wireless sessions need a selected charger in addition to the charged device.") .font(.caption) .foregroundColor(.secondary) } } } Button("Add Battery Checkpoint") { checkpointEditorVisibility = true } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) Button("Edit Device") { editingChargedDevice = selectedChargedDevice } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } else { Text("Select or create the device you are charging. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.") .font(.footnote) .foregroundColor(.secondary) } } .padding(18) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) } private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Charging Monitor") .font(.headline) ChargeRecordMetricsTableView( labels: ["Source", "Energy", "Charge", "Stop Threshold"], values: [ activeChargeSession.sourceMode.title, "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh", "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah", "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A" ] ) if let selectedChargedDevice, let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) { VStack(alignment: .leading, spacing: 4) { Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%") .font(.caption.weight(.semibold)) Text( "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." ) .font(.caption2) .foregroundColor(.secondary) } } if let targetBatteryPercent = activeChargeSession.targetBatteryPercent { Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%") .font(.caption.weight(.semibold)) } else { Text("No target battery notification configured.") .font(.caption) .foregroundColor(.secondary) } Button(activeChargeSession.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 activeChargeSession.targetBatteryPercent != nil { Button("Clear Target Notification") { _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) .buttonStyle(.plain) } if activeChargeSession.requiresCompletionConfirmation { completionConfirmationCard(activeChargeSession) } if let capacityEstimateWh = activeChargeSession.capacityEstimateWh { Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh") .font(.caption.weight(.semibold)) } Label( "Session charging type: \(activeChargeSession.chargingTransportMode.title)", systemImage: activeChargeSession.chargingTransportMode.symbolName ) .font(.caption) .foregroundColor(.secondary) if activeChargeSession.chargingTransportMode == .wireless { if let chargerID = activeChargeSession.chargerID, let charger = appData.chargedDeviceSummary(id: chargerID) { Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock") .font(.caption) .foregroundColor(.secondary) } else { Text("No wireless charger is currently selected for this session.") .font(.caption) .foregroundColor(.orange) } } if activeChargeSession.checkpoints.isEmpty == false { VStack(alignment: .leading, spacing: 8) { Text("Battery Checkpoints") .font(.subheadline.weight(.semibold)) ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in HStack { Text(checkpoint.timestamp.format()) .font(.caption2) .foregroundColor(.secondary) Spacer() Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%") .font(.caption.weight(.semibold)) Text("•") .foregroundColor(.secondary) Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh") .font(.caption2) .foregroundColor(.secondary) } } } } Text("The monitor prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) } private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Completion Needs Confirmation") .font(.subheadline.weight(.semibold)) if let contradictionPercent = activeChargeSession.completionContradictionPercent { Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.") .font(.caption) .foregroundColor(.secondary) } else { Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.") .font(.caption) .foregroundColor(.secondary) } if let targetBatteryPercent = activeChargeSession.targetBatteryPercent { Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.") .font(.caption2) .foregroundColor(.secondary) } Button("Finish Session") { _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) Button("Keep Monitoring") { _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } .padding(14) .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) } private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding { Binding( get: { effectiveChargingTransportMode(for: chargedDevice) }, set: { newValue in _ = appData.setChargingTransportMode(newValue, for: usbMeter) } ) } private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode { activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode } private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool { effectiveChargingTransportMode(for: chargedDevice) == .wireless } }