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