// // ChargeSessionCompletionSheetView.swift // USB Meter // import SwiftUI struct ChargeSessionCompletionSheetView: View { private enum FinalCheckpoint: String, CaseIterable, Identifiable { case full case skip case custom var id: String { rawValue } var label: String { switch self { case .full: return "Full" case .skip: return "Skip" case .custom: return "Other %" } } } @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss let sessionID: UUID let title: String let confirmTitle: String let explanation: String let monitoringMeter: Meter? let appliesTrim: Bool let trimStart: Date? let trimEnd: Date? @State private var batteryPercent = "" @State private var finalCheckpoint: FinalCheckpoint = .skip @State private var saveFailureMessage: String? init( sessionID: UUID, title: String, confirmTitle: String, explanation: String, monitoringMeter: Meter? = nil, appliesTrim: Bool = false, trimStart: Date? = nil, trimEnd: Date? = nil ) { self.sessionID = sessionID self.title = title self.confirmTitle = confirmTitle self.explanation = explanation self.monitoringMeter = monitoringMeter self.appliesTrim = appliesTrim self.trimStart = trimStart self.trimEnd = trimEnd } var body: some View { NavigationView { Form { Section( header: ContextInfoHeader( title: "Final Checkpoint", message: explanation ) ) { Picker("Final Battery", selection: $finalCheckpoint) { ForEach(FinalCheckpoint.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.segmented) if finalCheckpoint == .custom { TextField("Battery %", text: $batteryPercent) .keyboardType(.decimalPad) } } Section { if appliesTrim { Label("The selected trim window will be applied before the session is closed.", systemImage: "scissors") .font(.footnote) .foregroundColor(.blue) } if let saveFailureMessage { Label(saveFailureMessage, systemImage: "exclamationmark.triangle.fill") .font(.footnote) .foregroundColor(.red) } else if let saveDisabledReason { Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill") .font(.footnote) .foregroundColor(.red) } if hasChargeDataToSave == false { Button(role: .destructive) { _ = appData.deleteChargeSession(sessionID: sessionID) dismiss() } label: { Label("Discard Session", systemImage: "trash") } } else if saveDisabledReason == nil, let sessionWarning { Text(sessionWarning) .font(.footnote) .foregroundColor(.orange) } else if saveDisabledReason == nil, resolvedFinalBatteryPercent == 100 { 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 canSave else { saveFailureMessage = saveDisabledReason return } if appliesTrim { _ = appData.setSessionTrim( sessionID: sessionID, start: trimStart, end: trimEnd ) } if appData.stopChargeSession( sessionID: sessionID, finalBatteryPercent: resolvedFinalBatteryPercent, from: monitoringMeter ) { dismiss() } else { saveFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment." } } .disabled(!canSave) .opacity(canSave ? 1 : 0.45) } } } .navigationViewStyle(StackNavigationViewStyle()) .onChange(of: finalCheckpoint) { mode in saveFailureMessage = nil if mode == .custom { prefillFinalCheckpointIfNeeded() } else { batteryPercent = "" } } .onChange(of: batteryPercent) { _ in saveFailureMessage = nil } } private var session: ChargeSessionSummary? { appData.chargedDevices .flatMap(\.sessions) .first(where: { $0.id == sessionID }) } private var canSave: Bool { saveDisabledReason == nil } private var hasChargeDataToSave: Bool { guard let session else { return false } return session.hasSavableChargeData || displayedSessionEnergyWh(for: session) > 0 || displayedSessionChargeAh(for: session) > 0 } private var saveDisabledReason: String? { if finalCheckpoint == .custom { let trimmed = batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return "Enter the final battery percentage or choose Skip." } if parsedBatteryPercent == nil { return "Final battery percentage must be between 0 and 100." } } guard hasChargeDataToSave else { return "This session has no charging data to save. Discard it instead." } return nil } 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 resolvedFinalBatteryPercent: Double? { switch finalCheckpoint { case .full: return 100 case .skip: return nil case .custom: return parsedBatteryPercent } } private var suggestedFinalBatteryPercent: Double? { guard let session else { return nil } if let endBatteryPercent = session.endBatteryPercent { return endBatteryPercent } if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) { return latestCheckpoint.batteryPercent } return session.targetBatteryPercent ?? session.completionContradictionPercent } private func prefillFinalCheckpointIfNeeded() { guard batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let suggestedFinalBatteryPercent else { return } batteryPercent = suggestedFinalBatteryPercent.format(decimalDigits: 0) } private var sessionWarning: String? { guard let session, 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 func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double { let storedEnergyWh = session.effectiveOrMeasuredEnergyWh guard session.isTrimmed == false else { return storedEnergyWh } guard session.status.isOpen else { return storedEnergyWh } guard let monitoringMeter else { return storedEnergyWh } guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedEnergyWh } if let baselineEnergyWh = session.meterEnergyBaselineWh { return max(storedEnergyWh, max(monitoringMeter.recordedWH - baselineEnergyWh, 0)) } return storedEnergyWh } private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double { let storedChargeAh = session.measuredChargeAh guard session.isTrimmed == false else { return storedChargeAh } guard session.status.isOpen else { return storedChargeAh } guard let monitoringMeter else { return storedChargeAh } guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh } if let baselineChargeAh = session.meterChargeBaselineAh { return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0)) } return storedChargeAh } }