1 contributor
//
// MeterChargeRecordTabView.swift
// USB Meter
//
import SwiftUI
struct MeterChargeRecordTabView: 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
var body: some View {
ScrollView {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Charge Record")
.font(.system(.title3, design: .rounded).weight(.bold))
Spacer()
Text(usbMeter.chargeRecordStatusText)
.font(.caption.weight(.bold))
.foregroundColor(usbMeter.chargeRecordStatusColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.meterCard(
tint: usbMeter.chargeRecordStatusColor,
fillOpacity: 0.18,
strokeOpacity: 0.24,
cornerRadius: 999
)
}
Text("App-side charge accumulation based on the stop-threshold workflow.")
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(18)
.meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
chargedDeviceSection
if let activeChargeSession {
chargeMonitorSection(activeChargeSession)
}
ChargeRecordMetricsTableView(
labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
values: [
"\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
"\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
usbMeter.chargeRecordDurationDescription,
"\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
]
)
.padding(18)
.meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
if usbMeter.chargeRecordTimeRange != nil {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Charge Curve")
.font(.headline)
Spacer()
Button("Reset Graph") {
usbMeter.resetChargeRecordGraph()
}
.foregroundColor(.red)
}
MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
.environmentObject(usbMeter.measurements)
.frame(minHeight: 220)
Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(18)
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
}
VStack(alignment: .leading, spacing: 12) {
Text("Stop Threshold")
.font(.headline)
Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
.font(.footnote)
.foregroundColor(.secondary)
Button("Reset") {
usbMeter.resetChargeRecord()
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
.buttonStyle(.plain)
}
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
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 are reported by the meter for the active data group.")
.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)
}
}
.padding()
}
.background(
LinearGradient(
colors: [.pink.opacity(0.14), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.sheet(isPresented: $chargedDeviceLibraryVisibility) {
ChargedDeviceLibrarySheetView(
visibility: $chargedDeviceLibraryVisibility,
meterMACAddress: usbMeter.btSerial.macAddress.description,
meterTint: usbMeter.color,
mode: .device
)
.environmentObject(appData)
}
.sheet(isPresented: $chargerLibraryVisibility) {
ChargedDeviceLibrarySheetView(
visibility: $chargerLibraryVisibility,
meterMACAddress: usbMeter.btSerial.macAddress.description,
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 activeChargeSession {
BatteryTargetNotificationEditorSheetView(
sessionID: activeChargeSession.id,
initialTargetPercent: activeChargeSession.targetBatteryPercent
)
.environmentObject(appData)
}
}
}
private var selectedChargedDevice: ChargedDeviceSummary? {
appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
}
private var activeChargeSession: ChargeSessionSummary? {
appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
}
private var selectedCharger: ChargedDeviceSummary? {
appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
}
private var chargedDeviceSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Device")
.font(.headline)
Spacer()
Button("Library") {
chargedDeviceLibraryVisibility = true
}
}
if let selectedChargedDevice {
HStack(alignment: .top, spacing: 14) {
ChargedDeviceQRCodeView(
qrIdentifier: selectedChargedDevice.qrIdentifier,
side: 88
)
VStack(alignment: .leading, spacing: 8) {
Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
.font(.headline)
Text(selectedChargedDevice.deviceClass.title)
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
.font(.caption2)
.foregroundColor(.secondary)
if selectedChargedDevice.supportedChargingModes.count == 1 {
Label(
"Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
)
.font(.caption2)
.foregroundColor(.secondary)
} else {
Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
.tag(chargingTransportMode)
}
}
.pickerStyle(.segmented)
}
if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
.font(.caption)
.foregroundColor(.secondary)
} else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
.font(.caption)
.foregroundColor(.secondary)
}
if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
.font(.caption2)
.foregroundColor(.secondary)
} else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
}
if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
Divider()
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Wireless Charger")
.font(.subheadline.weight(.semibold))
Spacer()
Button(selectedCharger == nil ? "Select" : "Change") {
chargerLibraryVisibility = true
}
}
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 !selectedCharger.chargerObservedVoltageSelections.isEmpty {
Text(
"Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
.map { "\($0.format(decimalDigits: 1)) V" }
.joined(separator: ", ")
)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
} else {
Text("Wireless sessions need a selected charger in addition to the charged device.")
.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("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. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
}
private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Charging Monitor")
.font(.headline)
ChargeRecordMetricsTableView(
labels: ["Source", "Energy", "Charge", "Stop Threshold"],
values: [
activeChargeSession.sourceMode.title,
"\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
"\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
"\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
]
)
if let selectedChargedDevice,
let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
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 targetBatteryPercent = activeChargeSession.targetBatteryPercent {
Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
.font(.caption.weight(.semibold))
} else {
Text("No target battery notification configured.")
.font(.caption)
.foregroundColor(.secondary)
}
Button(activeChargeSession.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 activeChargeSession.targetBatteryPercent != nil {
Button("Clear Target Notification") {
_ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
.buttonStyle(.plain)
}
if activeChargeSession.requiresCompletionConfirmation {
completionConfirmationCard(activeChargeSession)
}
if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
.font(.caption.weight(.semibold))
}
Label(
"Session charging type: \(activeChargeSession.chargingTransportMode.title)",
systemImage: activeChargeSession.chargingTransportMode.symbolName
)
.font(.caption)
.foregroundColor(.secondary)
if activeChargeSession.chargingTransportMode == .wireless {
if let chargerID = activeChargeSession.chargerID,
let charger = appData.chargedDeviceSummary(id: chargerID) {
Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("No wireless charger is currently selected for this session.")
.font(.caption)
.foregroundColor(.orange)
}
}
if activeChargeSession.checkpoints.isEmpty == false {
VStack(alignment: .leading, spacing: 8) {
Text("Battery Checkpoints")
.font(.subheadline.weight(.semibold))
ForEach(activeChargeSession.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 prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(18)
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
}
private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Completion Needs Confirmation")
.font(.subheadline.weight(.semibold))
if let contradictionPercent = activeChargeSession.completionContradictionPercent {
Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
.font(.caption)
.foregroundColor(.secondary)
}
if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
.font(.caption2)
.foregroundColor(.secondary)
}
Button("Finish Session") {
_ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
}
.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: activeChargeSession.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 chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
Binding(
get: {
effectiveChargingTransportMode(for: chargedDevice)
},
set: { newValue in
_ = appData.setChargingTransportMode(newValue, for: usbMeter)
}
)
}
private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
}
private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
effectiveChargingTransportMode(for: chargedDevice) == .wireless
}
}