USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / ChargeSessionCompletionSheetView.swift
1 contributor
255 lines | 9.221kb
//
//  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
    }

    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? {
        nil
    }

    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
    }

}