Showing 5 changed files with 160 additions and 22 deletions
+4 -2
USB Meter/Model/AppData.swift
@@ -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
 
+10 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -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 }
+13 -0
USB Meter/Model/ChargeInsightsStore.swift
@@ -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
+81 -10
USB Meter/Views/ChargedDevices/ChargeSessionCompletionSheetView.swift
@@ -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),
+52 -10
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -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