@@ -580,8 +580,10 @@ final class AppData : ObservableObject {
|
||
| 580 | 580 |
} |
| 581 | 581 |
|
| 582 | 582 |
@discardableResult |
| 583 |
- func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
|
|
| 584 |
- if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
| 583 |
+ func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
|
|
| 584 |
+ if let meter {
|
|
| 585 |
+ _ = persistChargeSnapshot(from: meter) |
|
| 586 |
+ } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
| 585 | 587 |
_ = flushPendingChargeObservation(for: meterMACAddress) |
| 586 | 588 |
} |
| 587 | 589 |
|
@@ -609,6 +609,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 609 | 609 |
let maximumObservedCurrentAmps: Double? |
| 610 | 610 |
let maximumObservedPowerWatts: Double? |
| 611 | 611 |
let maximumObservedVoltageVolts: Double? |
| 612 |
+ let hasObservedChargeFlow: Bool |
|
| 612 | 613 |
let selectedSourceVoltageVolts: Double? |
| 613 | 614 |
let completionCurrentAmps: Double? |
| 614 | 615 |
let stopThresholdAmps: Double |
@@ -677,6 +678,15 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 677 | 678 |
effectiveBatteryEnergyWh ?? measuredEnergyWh |
| 678 | 679 |
} |
| 679 | 680 |
|
| 681 |
+ var hasSavableChargeData: Bool {
|
|
| 682 |
+ hasObservedChargeFlow |
|
| 683 |
+ || measuredEnergyWh > 0 |
|
| 684 |
+ || measuredChargeAh > 0 |
|
| 685 |
+ || (maximumObservedCurrentAmps ?? 0) > 0 |
|
| 686 |
+ || (maximumObservedPowerWatts ?? 0) > 0 |
|
| 687 |
+ || !aggregatedSamples.isEmpty |
|
| 688 |
+ } |
|
| 689 |
+ |
|
| 680 | 690 |
var batteryDeltaPercent: Double? {
|
| 681 | 691 |
guard let startBatteryPercent, let endBatteryPercent, |
| 682 | 692 |
startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
|
@@ -609,6 +609,10 @@ final class ChargeInsightsStore {
|
||
| 609 | 609 |
return |
| 610 | 610 |
} |
| 611 | 611 |
|
| 612 |
+ guard hasSavableChargeData(session) else {
|
|
| 613 |
+ return |
|
| 614 |
+ } |
|
| 615 |
+ |
|
| 612 | 616 |
let observedAt = snapshotDateForManualStop(session) |
| 613 | 617 |
finishSession( |
| 614 | 618 |
session, |
@@ -2462,6 +2466,7 @@ final class ChargeInsightsStore {
|
||
| 2462 | 2466 |
maximumObservedVoltageVolts: chargingTransportMode == .wired |
| 2463 | 2467 |
? optionalDoubleValue(object, key: "maximumObservedVoltageVolts") |
| 2464 | 2468 |
: nil, |
| 2469 |
+ hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"), |
|
| 2465 | 2470 |
selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"), |
| 2466 | 2471 |
completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"), |
| 2467 | 2472 |
stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"), |
@@ -3048,6 +3053,14 @@ final class ChargeInsightsStore {
|
||
| 3048 | 3053 |
return effectiveCurrent > max(stopThreshold ?? 0, 0.05) |
| 3049 | 3054 |
} |
| 3050 | 3055 |
|
| 3056 |
+ private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
|
|
| 3057 |
+ boolValue(session, key: "hasObservedChargeFlow") |
|
| 3058 |
+ || doubleValue(session, key: "measuredEnergyWh") > 0 |
|
| 3059 |
+ || doubleValue(session, key: "measuredChargeAh") > 0 |
|
| 3060 |
+ || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0 |
|
| 3061 |
+ || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 |
|
| 3062 |
+ } |
|
| 3063 |
+ |
|
| 3051 | 3064 |
private func derivedMinimumCurrent( |
| 3052 | 3065 |
from sessions: [NSManagedObject], |
| 3053 | 3066 |
chargingTransportMode: ChargingTransportMode |
@@ -6,6 +6,22 @@ |
||
| 6 | 6 |
import SwiftUI |
| 7 | 7 |
|
| 8 | 8 |
struct ChargeSessionCompletionSheetView: View {
|
| 9 |
+ private enum FinalCheckpoint: String, CaseIterable, Identifiable {
|
|
| 10 |
+ case full |
|
| 11 |
+ case skip |
|
| 12 |
+ case custom |
|
| 13 |
+ |
|
| 14 |
+ var id: String { rawValue }
|
|
| 15 |
+ |
|
| 16 |
+ var label: String {
|
|
| 17 |
+ switch self {
|
|
| 18 |
+ case .full: return "Full" |
|
| 19 |
+ case .skip: return "Skip" |
|
| 20 |
+ case .custom: return "Other %" |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 9 | 25 |
@EnvironmentObject private var appData: AppData |
| 10 | 26 |
@Environment(\.dismiss) private var dismiss |
| 11 | 27 |
|
@@ -15,6 +31,7 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 15 | 31 |
let explanation: String |
| 16 | 32 |
|
| 17 | 33 |
@State private var batteryPercent = "" |
| 34 |
+ @State private var finalCheckpoint: FinalCheckpoint = .skip |
|
| 18 | 35 |
|
| 19 | 36 |
var body: some View {
|
| 20 | 37 |
NavigationView {
|
@@ -25,16 +42,40 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 25 | 42 |
message: explanation |
| 26 | 43 |
) |
| 27 | 44 |
) {
|
| 28 |
- TextField("Battery %", text: $batteryPercent)
|
|
| 29 |
- .keyboardType(.decimalPad) |
|
| 45 |
+ Picker("Final Battery", selection: $finalCheckpoint) {
|
|
| 46 |
+ ForEach(FinalCheckpoint.allCases) { mode in
|
|
| 47 |
+ Text(mode.label).tag(mode) |
|
| 48 |
+ } |
|
| 49 |
+ } |
|
| 50 |
+ .pickerStyle(.segmented) |
|
| 51 |
+ |
|
| 52 |
+ if finalCheckpoint == .custom {
|
|
| 53 |
+ TextField("Battery %", text: $batteryPercent)
|
|
| 54 |
+ .keyboardType(.decimalPad) |
|
| 55 |
+ } |
|
| 30 | 56 |
} |
| 31 | 57 |
|
| 32 | 58 |
Section {
|
| 33 |
- if let sessionWarning {
|
|
| 59 |
+ if let refusalReason {
|
|
| 60 |
+ Label(refusalReason, systemImage: "exclamationmark.triangle.fill") |
|
| 61 |
+ .font(.footnote) |
|
| 62 |
+ .foregroundColor(.red) |
|
| 63 |
+ |
|
| 64 |
+ Button(role: .destructive) {
|
|
| 65 |
+ _ = appData.deleteChargeSession(sessionID: sessionID) |
|
| 66 |
+ dismiss() |
|
| 67 |
+ } label: {
|
|
| 68 |
+ Label("Discard Session", systemImage: "trash")
|
|
| 69 |
+ } |
|
| 70 |
+ } else if let customCheckpointWarning {
|
|
| 71 |
+ Label(customCheckpointWarning, systemImage: "exclamationmark.triangle.fill") |
|
| 72 |
+ .font(.footnote) |
|
| 73 |
+ .foregroundColor(.orange) |
|
| 74 |
+ } else if let sessionWarning {
|
|
| 34 | 75 |
Text(sessionWarning) |
| 35 | 76 |
.font(.footnote) |
| 36 | 77 |
.foregroundColor(.orange) |
| 37 |
- } else if (parsedBatteryPercent ?? 0) >= 99.5 {
|
|
| 78 |
+ } else if resolvedFinalBatteryPercent == 100 {
|
|
| 38 | 79 |
Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
|
| 39 | 80 |
.font(.footnote) |
| 40 | 81 |
.foregroundColor(.secondary) |
@@ -51,18 +92,42 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 51 | 92 |
} |
| 52 | 93 |
ToolbarItem(placement: .confirmationAction) {
|
| 53 | 94 |
Button(confirmTitle) {
|
| 54 |
- guard let percent = parsedBatteryPercent else { return }
|
|
| 55 |
- if appData.stopChargeSession(sessionID: sessionID, finalBatteryPercent: percent) {
|
|
| 95 |
+ guard canSave else { return }
|
|
| 96 |
+ if appData.stopChargeSession(sessionID: sessionID, finalBatteryPercent: resolvedFinalBatteryPercent) {
|
|
| 56 | 97 |
dismiss() |
| 57 | 98 |
} |
| 58 | 99 |
} |
| 59 |
- .disabled(parsedBatteryPercent == nil) |
|
| 100 |
+ .disabled(!canSave) |
|
| 101 |
+ .opacity(canSave ? 1 : 0.45) |
|
| 60 | 102 |
} |
| 61 | 103 |
} |
| 62 | 104 |
} |
| 63 | 105 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 64 | 106 |
} |
| 65 | 107 |
|
| 108 |
+ private var session: ChargeSessionSummary? {
|
|
| 109 |
+ appData.chargedDevices |
|
| 110 |
+ .flatMap(\.sessions) |
|
| 111 |
+ .first(where: { $0.id == sessionID })
|
|
| 112 |
+ } |
|
| 113 |
+ |
|
| 114 |
+ private var canSave: Bool {
|
|
| 115 |
+ session?.hasSavableChargeData == true |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ private var refusalReason: String? {
|
|
| 119 |
+ canSave ? nil : "This session has no charging data to save. Discard it instead." |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ private var customCheckpointWarning: String? {
|
|
| 123 |
+ guard finalCheckpoint == .custom, |
|
| 124 |
+ batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, |
|
| 125 |
+ parsedBatteryPercent == nil else {
|
|
| 126 |
+ return nil |
|
| 127 |
+ } |
|
| 128 |
+ return "Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint." |
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 66 | 131 |
private var parsedBatteryPercent: Double? {
|
| 67 | 132 |
let normalized = batteryPercent |
| 68 | 133 |
.trimmingCharacters(in: .whitespacesAndNewlines) |
@@ -71,10 +136,16 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 71 | 136 |
return value |
| 72 | 137 |
} |
| 73 | 138 |
|
| 139 |
+ private var resolvedFinalBatteryPercent: Double? {
|
|
| 140 |
+ switch finalCheckpoint {
|
|
| 141 |
+ case .full: return 100 |
|
| 142 |
+ case .skip: return nil |
|
| 143 |
+ case .custom: return parsedBatteryPercent |
|
| 144 |
+ } |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 74 | 147 |
private var sessionWarning: String? {
|
| 75 |
- guard let session = appData.chargedDevices |
|
| 76 |
- .flatMap(\.sessions) |
|
| 77 |
- .first(where: { $0.id == sessionID }),
|
|
| 148 |
+ guard let session, |
|
| 78 | 149 |
session.chargingTransportMode == .wireless, |
| 79 | 150 |
let chargerID = session.chargerID, |
| 80 | 151 |
let charger = appData.chargedDeviceSummary(id: chargerID), |
@@ -780,7 +780,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 780 | 780 |
) |
| 781 | 781 |
|
| 782 | 782 |
if showingStopConfirm {
|
| 783 |
- stopConfirmPanel(for: openChargeSession) |
|
| 783 |
+ stopConfirmPanel( |
|
| 784 |
+ for: openChargeSession, |
|
| 785 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 786 |
+ displayedChargeAh: displayedChargeAh |
|
| 787 |
+ ) |
|
| 784 | 788 |
} else {
|
| 785 | 789 |
HStack(spacing: 10) {
|
| 786 | 790 |
if openChargeSession.status == .active {
|
@@ -1199,8 +1203,21 @@ struct MeterChargeRecordContentView: View {
|
||
| 1199 | 1203 |
return value |
| 1200 | 1204 |
} |
| 1201 | 1205 |
|
| 1202 |
- private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
|
|
| 1203 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 1206 |
+ private func stopConfirmPanel( |
|
| 1207 |
+ for session: ChargeSessionSummary, |
|
| 1208 |
+ displayedEnergyWh: Double, |
|
| 1209 |
+ displayedChargeAh: Double |
|
| 1210 |
+ ) -> some View {
|
|
| 1211 |
+ let canSave = hasSavableChargeData( |
|
| 1212 |
+ for: session, |
|
| 1213 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 1214 |
+ displayedChargeAh: displayedChargeAh |
|
| 1215 |
+ ) |
|
| 1216 |
+ let hasInvalidCustomCheckpoint = finalCheckpointMode == .custom |
|
| 1217 |
+ && finalCheckpointText.isEmpty == false |
|
| 1218 |
+ && parsedFinalCheckpoint == nil |
|
| 1219 |
+ |
|
| 1220 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 1204 | 1221 |
Text("Final Checkpoint (optional)")
|
| 1205 | 1222 |
.font(.subheadline.weight(.semibold)) |
| 1206 | 1223 |
|
@@ -1256,6 +1273,18 @@ struct MeterChargeRecordContentView: View {
|
||
| 1256 | 1273 |
} |
| 1257 | 1274 |
} |
| 1258 | 1275 |
|
| 1276 |
+ if !canSave {
|
|
| 1277 |
+ Label("This session has no charging data to save. Discard it instead.", systemImage: "exclamationmark.triangle.fill")
|
|
| 1278 |
+ .font(.caption) |
|
| 1279 |
+ .foregroundColor(.red) |
|
| 1280 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 1281 |
+ } else if hasInvalidCustomCheckpoint {
|
|
| 1282 |
+ Label("Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint.", systemImage: "exclamationmark.triangle.fill")
|
|
| 1283 |
+ .font(.caption) |
|
| 1284 |
+ .foregroundColor(.orange) |
|
| 1285 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 1286 |
+ } |
|
| 1287 |
+ |
|
| 1259 | 1288 |
HStack(spacing: 8) {
|
| 1260 | 1289 |
Button("Discard") {
|
| 1261 | 1290 |
_ = appData.deleteChargeSession(sessionID: session.id) |
@@ -1268,14 +1297,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 1268 | 1297 |
.meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) |
| 1269 | 1298 |
.buttonStyle(.plain) |
| 1270 | 1299 |
|
| 1271 |
- let saveDisabled = finalCheckpointMode == .custom |
|
| 1272 |
- && finalCheckpointText.isEmpty == false |
|
| 1273 |
- && parsedFinalCheckpoint == nil |
|
| 1274 |
- |
|
| 1275 | 1300 |
Button("Save") {
|
| 1276 | 1301 |
_ = appData.stopChargeSession( |
| 1277 | 1302 |
sessionID: session.id, |
| 1278 |
- finalBatteryPercent: resolvedFinalCheckpoint |
|
| 1303 |
+ finalBatteryPercent: resolvedFinalCheckpoint, |
|
| 1304 |
+ from: usbMeter |
|
| 1279 | 1305 |
) |
| 1280 | 1306 |
showingStopConfirm = false |
| 1281 | 1307 |
finalCheckpointText = "" |
@@ -1283,9 +1309,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 1283 | 1309 |
} |
| 1284 | 1310 |
.frame(maxWidth: .infinity) |
| 1285 | 1311 |
.padding(.vertical, 9) |
| 1286 |
- .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 1312 |
+ .meterCard( |
|
| 1313 |
+ tint: canSave ? .green : .red, |
|
| 1314 |
+ fillOpacity: canSave ? 0.16 : 0.08, |
|
| 1315 |
+ strokeOpacity: canSave ? 0.22 : 0.28, |
|
| 1316 |
+ cornerRadius: 14 |
|
| 1317 |
+ ) |
|
| 1287 | 1318 |
.buttonStyle(.plain) |
| 1288 |
- .disabled(saveDisabled) |
|
| 1319 |
+ .disabled(!canSave) |
|
| 1320 |
+ .opacity(canSave ? 1 : 0.52) |
|
| 1289 | 1321 |
|
| 1290 | 1322 |
Button("Cancel") {
|
| 1291 | 1323 |
showingStopConfirm = false |
@@ -1324,6 +1356,16 @@ struct MeterChargeRecordContentView: View {
|
||
| 1324 | 1356 |
finalCheckpointText = next.format(decimalDigits: 0) |
| 1325 | 1357 |
} |
| 1326 | 1358 |
|
| 1359 |
+ private func hasSavableChargeData( |
|
| 1360 |
+ for session: ChargeSessionSummary, |
|
| 1361 |
+ displayedEnergyWh: Double, |
|
| 1362 |
+ displayedChargeAh: Double |
|
| 1363 |
+ ) -> Bool {
|
|
| 1364 |
+ session.hasSavableChargeData |
|
| 1365 |
+ || displayedEnergyWh > 0 |
|
| 1366 |
+ || displayedChargeAh > 0 |
|
| 1367 |
+ } |
|
| 1368 |
+ |
|
| 1327 | 1369 |
// MARK: - Trim Detection Banner |
| 1328 | 1370 |
|
| 1329 | 1371 |
@ViewBuilder |