1 contributor
//
// 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? {
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
}
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
}
}