// // MeterChargeRecordTabView.swift // USB Meter // import SwiftUI struct MeterChargeRecordTabView: View { var body: some View { MeterChargeRecordContentView() } } struct MeterChargeRecordContentView: 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 @State private var pendingStopRequest: ChargeSessionStopRequest? @State private var draftChargingTransportMode: ChargingTransportMode? @State private var draftChargingStateMode: ChargingStateMode? @State private var draftAutoStopEnabled = true @State private var initialCheckpoint = "" var body: some View { ScrollView { VStack(spacing: 16) { headerCard sessionSetupCard if let openChargeSession { chargingMonitorCard(openChargeSession) if let sessionChartTimeRange { sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession) } } if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 { meterTotalsCard } } .padding() } .background( LinearGradient( colors: [.pink.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .sheet(isPresented: $chargedDeviceLibraryVisibility) { ChargedDeviceLibrarySheetView( visibility: $chargedDeviceLibraryVisibility, meterMACAddress: meterMACAddress, meterTint: usbMeter.color, mode: .device ) .environmentObject(appData) } .sheet(isPresented: $chargerLibraryVisibility) { ChargedDeviceLibrarySheetView( visibility: $chargerLibraryVisibility, meterMACAddress: meterMACAddress, 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 openChargeSession { BatteryTargetNotificationEditorSheetView( sessionID: openChargeSession.id, initialTargetPercent: openChargeSession.targetBatteryPercent ) .environmentObject(appData) } } .sheet(item: $pendingStopRequest) { request in ChargeSessionCompletionSheetView( sessionID: request.sessionID, title: request.title, confirmTitle: request.confirmTitle, explanation: request.explanation ) .environmentObject(appData) } .onAppear { syncDraftSelections() } .onChange(of: selectedChargedDevice?.id) { _ in syncDraftSelections() } .onChange(of: openChargeSession?.id) { _ in syncDraftSelections() } } private var meterMACAddress: String { usbMeter.btSerial.macAddress.description } private var selectedChargedDevice: ChargedDeviceSummary? { appData.currentChargedDeviceSummary(for: meterMACAddress) } private var selectedCharger: ChargedDeviceSummary? { appData.currentChargerSummary(for: meterMACAddress) } private var openChargeSession: ChargeSessionSummary? { appData.activeChargeSessionSummary(for: meterMACAddress) } private var selectedDraftTransportMode: ChargingTransportMode? { openChargeSession?.chargingTransportMode ?? draftChargingTransportMode } private var selectedDraftChargingStateMode: ChargingStateMode? { openChargeSession?.chargingStateMode ?? draftChargingStateMode } private var selectedDraftSessionKind: ChargeSessionKind? { guard let chargingTransportMode = selectedDraftTransportMode, let chargingStateMode = selectedDraftChargingStateMode else { return nil } return ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ) } private var selectedDraftStopThreshold: Double? { guard let selectedChargedDevice, let chargingTransportMode = selectedDraftTransportMode else { return nil } return selectedChargedDevice.resolvedCompletionCurrentAmps( for: chargingTransportMode, chargingStateMode: selectedDraftChargingStateMode ) } private var initialCheckpointValue: Double? { let normalized = initialCheckpoint .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard let value = Double(normalized), value >= 0, value <= 100 else { return nil } return value } private var requiresExplicitTransportSelection: Bool { (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1 } private var requiresExplicitChargingStateSelection: Bool { (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1 } private var canStartSession: Bool { guard openChargeSession == nil, let selectedChargedDevice, let chargingTransportMode = selectedDraftTransportMode, let chargingStateMode = selectedDraftChargingStateMode, let initialCheckpointValue else { return false } guard selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) else { return false } guard selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) else { return false } if chargingTransportMode == .wireless { return selectedCharger != nil } return true } private var headerStatusTitle: String { guard let openChargeSession else { return "Idle" } return openChargeSession.status.title } private var headerStatusColor: Color { guard let openChargeSession else { return .secondary } switch openChargeSession.status { case .active: return .red case .paused: return .orange case .completed: return .green case .abandoned: return .secondary } } private var sessionChartTimeRange: ClosedRange? { guard let openChargeSession else { return nil } let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt return openChargeSession.startedAt...max(end, openChargeSession.startedAt) } private var draftAutoStopDescription: String { guard let chargingTransportMode = selectedDraftTransportMode else { return "Choose the charging type before starting the session." } if chargingTransportMode == .wireless, selectedCharger == nil { return "Wireless sessions need a selected charger before they can start." } if draftAutoStopEnabled == false { return "The session starts open-ended and will stop only when you pause or stop it manually." } if let setupWarning = setupWirelessThresholdWarning { return setupWarning } if let selectedDraftSessionKind, let selectedDraftStopThreshold { return "Auto-stop is ready for \(selectedDraftSessionKind.shortTitle.lowercased()) sessions at about \(selectedDraftStopThreshold.format(decimalDigits: 2)) A." } return "No stop threshold is known for this charging type yet, so the session starts open-ended." } private var setupWirelessThresholdWarning: String? { guard selectedDraftTransportMode == .wireless else { return nil } guard let selectedCharger else { return nil } guard selectedCharger.chargerIdleCurrentAmps == nil else { return nil } return "This charger has no idle-current measurement. Wireless sessions can still be recorded, but they cannot learn or auto-apply the final stop threshold yet." } private var headerCard: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Charging Session") .font(.system(.title3, design: .rounded).weight(.bold)) Spacer() Text(headerStatusTitle) .font(.caption.weight(.bold)) .foregroundColor(headerStatusColor) .padding(.horizontal, 10) .padding(.vertical, 6) .meterCard( tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999 ) } Text("Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit.") .font(.footnote) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding(18) .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) } private var sessionSetupCard: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text(openChargeSession == nil ? "Session Setup" : "Session Context") .font(.headline) Spacer() Button("Library") { chargedDeviceLibraryVisibility = true } .disabled(openChargeSession != nil) } if let selectedChargedDevice { deviceSummary(selectedChargedDevice) if openChargeSession == nil { setupControls(for: selectedChargedDevice) } 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. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection.") .font(.footnote) .foregroundColor(.secondary) } } .padding(18) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) } private func deviceSummary(_ chargedDevice: ChargedDeviceSummary) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 14) { ChargedDeviceQRCodeView( qrIdentifier: chargedDevice.qrIdentifier, side: 88 ) VStack(alignment: .leading, spacing: 8) { Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) .font(.headline) Text(chargedDevice.deviceClass.title) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) Text(chargedDevice.chargingStateAvailability.description) .font(.caption2) .foregroundColor(.secondary) Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) .font(.caption2) .foregroundColor(.secondary) if let selectedDraftSessionKind, let threshold = chargedDevice.resolvedCompletionCurrentAmps( for: selectedDraftSessionKind.chargingTransportMode, chargingStateMode: selectedDraftSessionKind.chargingStateMode ) { Text("\(selectedDraftSessionKind.shortTitle) stop current: \(threshold.format(decimalDigits: 2)) A") .font(.caption2) .foregroundColor(.secondary) } else if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh( for: chargedDevice.preferredChargingTransportMode ) { Text("Estimated \(chargedDevice.preferredChargingTransportMode.title.lowercased()) capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh") .font(.caption2) .foregroundColor(.secondary) } } Spacer(minLength: 0) } if showsWirelessChargerSection { Divider() wirelessChargerSection } } } private func setupControls(for chargedDevice: ChargedDeviceSummary) -> some View { VStack(alignment: .leading, spacing: 12) { if requiresExplicitTransportSelection { VStack(alignment: .leading, spacing: 8) { Text("Charging Type") .font(.subheadline.weight(.semibold)) Picker("Charging Type", selection: $draftChargingTransportMode) { ForEach(chargedDevice.supportedChargingModes) { chargingTransportMode in Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName) .tag(Optional(chargingTransportMode)) } } .pickerStyle(.segmented) if draftChargingTransportMode == nil { Text("Pick the charging type explicitly before starting.") .font(.caption2) .foregroundColor(.orange) } } } else if let chargingTransportMode = chargedDevice.supportedChargingModes.first { Label( "Charging type: \(chargingTransportMode.title)", systemImage: chargingTransportMode.symbolName ) .font(.subheadline.weight(.semibold)) } if requiresExplicitChargingStateSelection { VStack(alignment: .leading, spacing: 8) { Text("Charging Mode") .font(.subheadline.weight(.semibold)) Picker("Charging Mode", selection: $draftChargingStateMode) { ForEach(chargedDevice.supportedChargingStateModes) { chargingStateMode in Text(chargingStateMode.title) .tag(Optional(chargingStateMode)) } } .pickerStyle(.segmented) if draftChargingStateMode == nil { Text("Pick whether the device is on or off for this session.") .font(.caption2) .foregroundColor(.orange) } } } else if let chargingStateMode = chargedDevice.supportedChargingStateModes.first { Label( "Charging mode: \(chargingStateMode.title)", systemImage: chargingStateMode == .off ? "power.circle" : "power" ) .font(.subheadline.weight(.semibold)) } VStack(alignment: .leading, spacing: 8) { Text("Initial Checkpoint") .font(.subheadline.weight(.semibold)) TextField("Battery %", text: $initialCheckpoint) .keyboardType(.decimalPad) Text("The session starts only after this first checkpoint is recorded.") .font(.caption2) .foregroundColor(.secondary) } Toggle("Auto-stop when the type already has a stop threshold", isOn: $draftAutoStopEnabled) Text(draftAutoStopDescription) .font(.footnote) .foregroundColor(setupWirelessThresholdWarning == nil ? .secondary : .orange) Button("Start Session") { startSession() } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) .disabled(!canStartSession) } } private var wirelessChargerSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Wireless Charger") .font(.subheadline.weight(.semibold)) Spacer() Button(selectedCharger == nil ? "Select" : "Change") { chargerLibraryVisibility = true } .disabled(openChargeSession != nil) } 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 let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps { Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A") .font(.caption2) .foregroundColor(.secondary) } else { Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.") .font(.caption2) .foregroundColor(.orange) } } } } else { Text("Wireless sessions need a selected charger in addition to the charged device.") .font(.caption) .foregroundColor(.secondary) } } } private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Charging Monitor") .font(.headline) ChargeRecordMetricsTableView( labels: ["Type", "Mode", "Energy", "Auto Stop"], values: [ openChargeSession.chargingTransportMode.title, openChargeSession.chargingStateMode.title, "\(openChargeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh", autoStopLabel(for: openChargeSession) ] ) if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(for: openChargeSession) { 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 sessionWarning = sessionWarning(for: openChargeSession) { Text(sessionWarning) .font(.caption) .foregroundColor(.orange) } if openChargeSession.isPaused { Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.") .font(.caption) .foregroundColor(.secondary) } if openChargeSession.requiresCompletionConfirmation { completionConfirmationCard(openChargeSession) } if let targetBatteryPercent = openChargeSession.targetBatteryPercent { Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%") .font(.caption.weight(.semibold)) } else { Text("No target battery notification configured.") .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(openChargeSession.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 openChargeSession.targetBatteryPercent != nil { Button("Clear Target Notification") { _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) .buttonStyle(.plain) } if openChargeSession.status == .active { Button("Pause Session") { _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } else if openChargeSession.status == .paused { Button("Resume Session") { _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } Button("Stop Session") { pendingStopRequest = ChargeSessionStopRequest( sessionID: openChargeSession.id, title: "Stop Session", confirmTitle: "Stop", explanation: "Record 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 !openChargeSession.checkpoints.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Battery Checkpoints") .font(.subheadline.weight(.semibold)) ForEach(openChargeSession.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 keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) } private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Completion Needs Confirmation") .font(.subheadline.weight(.semibold)) if let contradictionPercent = openChargeSession.completionContradictionPercent { Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.") .font(.caption) .foregroundColor(.secondary) } else { Text("Current dropped to the learned stop threshold, but the battery prediction does not look like a normal finish yet.") .font(.caption) .foregroundColor(.secondary) } Button("Finish Session With Final Checkpoint") { pendingStopRequest = ChargeSessionStopRequest( sessionID: openChargeSession.id, title: "Finish Session", confirmTitle: "Finish", explanation: "Add the final checkpoint before confirming the stop." ) } .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: openChargeSession.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 sessionChartCard( timeRange: ClosedRange, session: ChargeSessionSummary ) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Session Chart") .font(.headline) MeasurementChartView(timeRange: timeRange) .environmentObject(usbMeter.measurements) .frame(minHeight: 220) Text("The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) } private var meterTotalsCard: some View { 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 come directly from the meter and remain separate from the explicit app session controls.") .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) } private var showsWirelessChargerSection: Bool { let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first return transportMode == .wireless } private func autoStopLabel(for session: ChargeSessionSummary) -> String { if session.autoStopEnabled == false { return "Manual" } if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless { return sessionWarning.contains("idle-current") ? "Blocked" : "Manual" } 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) else { return nil } guard charger.chargerIdleCurrentAmps == nil else { return nil } return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session." } private func startSession() { guard let selectedChargedDevice, let chargingTransportMode = selectedDraftTransportMode, let chargingStateMode = selectedDraftChargingStateMode, let initialCheckpointValue else { return } let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil let didStart = appData.startChargeSession( for: usbMeter, chargedDeviceID: selectedChargedDevice.id, chargerID: chargerID, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, autoStopEnabled: draftAutoStopEnabled, initialBatteryPercent: initialCheckpointValue ) if didStart { initialCheckpoint = "" } } private func syncDraftSelections() { guard let selectedChargedDevice else { draftChargingTransportMode = nil draftChargingStateMode = nil draftAutoStopEnabled = true return } if let openChargeSession { draftChargingTransportMode = openChargeSession.chargingTransportMode draftChargingStateMode = openChargeSession.chargingStateMode draftAutoStopEnabled = openChargeSession.autoStopEnabled return } if let draftChargingTransportMode, selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false { self.draftChargingTransportMode = nil } if let draftChargingStateMode, selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false { self.draftChargingStateMode = nil } if selectedChargedDevice.supportedChargingModes.count == 1 { draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first } if selectedChargedDevice.supportedChargingStateModes.count == 1 { draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first } } } struct ChargeSessionCompletionSheetView: View { @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss let sessionID: UUID let title: String let confirmTitle: String let explanation: String @State private var batteryPercent = "" @State private var label = "Final" var body: some View { NavigationView { Form { Section(header: Text("Final Checkpoint")) { TextField("Battery %", text: $batteryPercent) .keyboardType(.decimalPad) TextField("Label", text: $label) } Section { Text(explanation) .font(.footnote) .foregroundColor(.secondary) if let sessionWarning { Text(sessionWarning) .font(.footnote) .foregroundColor(.orange) } else if (parsedBatteryPercent ?? 0) >= 99.5 { Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.") .font(.footnote) .foregroundColor(.secondary) } } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button(confirmTitle) { guard let batteryPercent = parsedBatteryPercent else { return } if appData.stopChargeSession( sessionID: sessionID, finalBatteryPercent: batteryPercent, label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label ) { dismiss() } } .disabled(parsedBatteryPercent == nil) } } } .navigationViewStyle(StackNavigationViewStyle()) } private var parsedBatteryPercent: Double? { let normalized = batteryPercent .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ",", with: ".") guard let value = Double(normalized), value >= 0, value <= 100 else { return nil } return value } private var sessionWarning: String? { guard let session = appData.chargedDevices .flatMap(\.sessions) .first(where: { $0.id == sessionID }), 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 final checkpoint will stop the session but will not learn a wireless stop threshold yet." } } private struct ChargeSessionStopRequest: Identifiable { let sessionID: UUID let title: String let confirmTitle: String let explanation: String var id: UUID { sessionID } }