Showing 11 changed files with 2042 additions and 990 deletions
+13 -0
Documentation/Charging While Off.md
@@ -27,10 +27,23 @@ Off-state charging sessions tend to produce the cleanest signal for estimating b
27 27
 
28 28
 ## App implications
29 29
 
30
+### Explicit session setup
31
+
32
+- Starting a charge session should be **explicit**: the user picks the device, the charging type (**wired** / **wireless**), and the charging mode (**on** / **off**) whenever the device supports both.
33
+- Wireless sessions also require the user to pick the **charger** that is being used.
34
+- The app should ask for an **initial battery checkpoint** before the session begins.
35
+- If no stop threshold is known for the selected combination of charging type + charging mode, the session should remain **open-ended** until the user pauses or stops it.
36
+
30 37
 ### Defaults
31 38
 
32 39
 - **Default assumption (for capacity learning):** if the user does not specify otherwise, a device is treated as **on/unknown-state** while charging.
33 40
 
41
+### Stop-threshold learning for wireless charging
42
+
43
+- Wireless stop-threshold learning must subtract the charger's **idle current** (no-load current).
44
+- Without that idle-current measurement, the app must **not** learn a wireless end-of-charge threshold from the session.
45
+- The UI should warn both in the **charger detail** and in the **session view** when that idle-current measurement is missing.
46
+
34 47
 ### Capacity estimation priority (conceptual rule)
35 48
 
36 49
 When we have both:
+68 -16
USB Meter/Model/AppData.swift
@@ -204,26 +204,24 @@ final class AppData : ObservableObject {
204 204
     func createChargedDevice(
205 205
         name: String,
206 206
         deviceClass: ChargedDeviceClass,
207
-        supportsChargingWhileOff: Bool,
207
+        chargingStateAvailability: ChargingStateAvailability,
208 208
         supportsWiredCharging: Bool,
209 209
         supportsWirelessCharging: Bool,
210 210
         preferredChargingTransportMode: ChargingTransportMode,
211 211
         wirelessChargingProfile: WirelessChargingProfile,
212
-        wiredChargeCompletionCurrentAmps: Double?,
213
-        wirelessChargeCompletionCurrentAmps: Double?,
212
+        configuredCompletionCurrents: [ChargeSessionKind: Double],
214 213
         notes: String?,
215 214
         meterMACAddress: String?
216 215
     ) -> Bool {
217 216
         let didSave = chargeInsightsStore?.createChargedDevice(
218 217
             name: name,
219 218
             deviceClass: deviceClass,
220
-            supportsChargingWhileOff: supportsChargingWhileOff,
219
+            chargingStateAvailability: chargingStateAvailability,
221 220
             supportsWiredCharging: supportsWiredCharging,
222 221
             supportsWirelessCharging: supportsWirelessCharging,
223 222
             preferredChargingTransportMode: preferredChargingTransportMode,
224 223
             wirelessChargingProfile: wirelessChargingProfile,
225
-            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
226
-            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
224
+            configuredCompletionCurrents: configuredCompletionCurrents,
227 225
             notes: notes,
228 226
             assignTo: meterMACAddress
229 227
         ) ?? false
@@ -240,26 +238,24 @@ final class AppData : ObservableObject {
240 238
         id: UUID,
241 239
         name: String,
242 240
         deviceClass: ChargedDeviceClass,
243
-        supportsChargingWhileOff: Bool,
241
+        chargingStateAvailability: ChargingStateAvailability,
244 242
         supportsWiredCharging: Bool,
245 243
         supportsWirelessCharging: Bool,
246 244
         preferredChargingTransportMode: ChargingTransportMode,
247 245
         wirelessChargingProfile: WirelessChargingProfile,
248
-        wiredChargeCompletionCurrentAmps: Double?,
249
-        wirelessChargeCompletionCurrentAmps: Double?,
246
+        configuredCompletionCurrents: [ChargeSessionKind: Double],
250 247
         notes: String?
251 248
     ) -> Bool {
252 249
         let didSave = chargeInsightsStore?.updateChargedDevice(
253 250
             id: id,
254 251
             name: name,
255 252
             deviceClass: deviceClass,
256
-            supportsChargingWhileOff: supportsChargingWhileOff,
253
+            chargingStateAvailability: chargingStateAvailability,
257 254
             supportsWiredCharging: supportsWiredCharging,
258 255
             supportsWirelessCharging: supportsWirelessCharging,
259 256
             preferredChargingTransportMode: preferredChargingTransportMode,
260 257
             wirelessChargingProfile: wirelessChargingProfile,
261
-            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
262
-            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
258
+            configuredCompletionCurrents: configuredCompletionCurrents,
263 259
             notes: notes
264 260
         ) ?? false
265 261
 
@@ -303,12 +299,65 @@ final class AppData : ObservableObject {
303 299
     }
304 300
 
305 301
     @discardableResult
306
-    func ensureChargeSession(for meter: Meter) -> Bool {
302
+    func startChargeSession(
303
+        for meter: Meter,
304
+        chargedDeviceID: UUID,
305
+        chargerID: UUID?,
306
+        chargingTransportMode: ChargingTransportMode,
307
+        chargingStateMode: ChargingStateMode,
308
+        autoStopEnabled: Bool,
309
+        initialBatteryPercent: Double
310
+    ) -> Bool {
307 311
         guard let snapshot = meter.chargingMonitorSnapshot else {
308 312
             return false
309 313
         }
310 314
 
311
-        let didSave = chargeInsightsStore?.ensureSession(for: snapshot, forceStart: true) ?? false
315
+        let didSave = chargeInsightsStore?.startSession(
316
+            for: snapshot,
317
+            chargedDeviceID: chargedDeviceID,
318
+            chargerID: chargerID,
319
+            chargingTransportMode: chargingTransportMode,
320
+            chargingStateMode: chargingStateMode,
321
+            autoStopEnabled: autoStopEnabled,
322
+            initialBatteryPercent: initialBatteryPercent
323
+        ) ?? false
324
+        if didSave {
325
+            reloadChargedDevices()
326
+        }
327
+        return didSave
328
+    }
329
+
330
+    @discardableResult
331
+    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
332
+        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
333
+        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
334
+        if didSave {
335
+            reloadChargedDevices()
336
+        }
337
+        return didSave
338
+    }
339
+
340
+    @discardableResult
341
+    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
342
+        let snapshot = meter?.chargingMonitorSnapshot
343
+        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
344
+        if didSave {
345
+            reloadChargedDevices()
346
+        }
347
+        return didSave
348
+    }
349
+
350
+    @discardableResult
351
+    func stopChargeSession(
352
+        sessionID: UUID,
353
+        finalBatteryPercent: Double,
354
+        label: String? = "Final"
355
+    ) -> Bool {
356
+        let didSave = chargeInsightsStore?.stopSession(
357
+            id: sessionID,
358
+            finalBatteryPercent: finalBatteryPercent,
359
+            label: label
360
+        ) ?? false
312 361
         if didSave {
313 362
             reloadChargedDevices()
314 363
         }
@@ -412,7 +461,7 @@ final class AppData : ObservableObject {
412 461
             return false
413 462
         }
414 463
 
415
-        if deletedSession?.status == .active,
464
+        if deletedSession?.status.isOpen == true,
416 465
            let meterMACAddress = deletedSession?.meterMACAddress,
417 466
            let liveMeter = meter(for: meterMACAddress) {
418 467
             liveMeter.resetChargeRecord()
@@ -431,7 +480,7 @@ final class AppData : ObservableObject {
431 480
         }
432 481
 
433 482
         if deletedDevice?.isCharger == false,
434
-           deletedDevice?.activeSession?.status == .active,
483
+           deletedDevice?.activeSession?.status.isOpen == true,
435 484
            let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
436 485
            let liveMeter = meter(for: meterMACAddress) {
437 486
             liveMeter.resetChargeRecord()
@@ -486,6 +535,9 @@ final class AppData : ObservableObject {
486 535
         guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
487 536
             return
488 537
         }
538
+        guard activeSession.status == .active else {
539
+            return
540
+        }
489 541
         meter.restoreChargeMonitoringIfNeeded(from: activeSession)
490 542
     }
491 543
 
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter 9.xcdatamodel</string>
6
+	<string>USB_Meter 10.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+121 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 10.xcdatamodel/contents
@@ -0,0 +1,121 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
9
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
10
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
11
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
25
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
26
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
29
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
37
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
38
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
39
+        <attribute name="meterName" optional="YES" attributeType="String"/>
40
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
41
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
42
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
46
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
50
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
51
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
56
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
60
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
65
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
67
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
70
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
71
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
74
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
75
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
76
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
77
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
78
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
80
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
81
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
83
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
85
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+    </entity>
87
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
88
+        <attribute name="id" optional="YES" attributeType="String"/>
89
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
90
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
91
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
92
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
93
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
94
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
95
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
96
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
97
+        <attribute name="label" optional="YES" attributeType="String"/>
98
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
99
+    </entity>
100
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
101
+        <attribute name="id" optional="YES" attributeType="String"/>
102
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
103
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
104
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
105
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
106
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
107
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
108
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
109
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
110
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
111
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
112
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
113
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
114
+    </entity>
115
+    <elements>
116
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="418"/>
117
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/>
118
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
119
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
120
+    </elements>
121
+</model>
+226 -7
USB Meter/Model/ChargeInsightsModel.swift
@@ -49,11 +49,30 @@ enum ChargedDeviceClass: String, CaseIterable, Identifiable {
49 49
 
50 50
 enum ChargeSessionStatus: String {
51 51
     case active
52
+    case paused
52 53
     case completed
53 54
     case abandoned
54 55
 
55 56
     var title: String {
56
-        rawValue.capitalized
57
+        switch self {
58
+        case .active:
59
+            return "Active"
60
+        case .paused:
61
+            return "Paused"
62
+        case .completed:
63
+            return "Completed"
64
+        case .abandoned:
65
+            return "Abandoned"
66
+        }
67
+    }
68
+
69
+    var isOpen: Bool {
70
+        switch self {
71
+        case .active, .paused:
72
+            return true
73
+        case .completed, .abandoned:
74
+            return false
75
+        }
57 76
     }
58 77
 }
59 78
 
@@ -74,7 +93,7 @@ enum ChargeSessionSourceMode: String {
74 93
     }
75 94
 }
76 95
 
77
-enum ChargingTransportMode: String, CaseIterable, Identifiable {
96
+enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
78 97
     case wired
79 98
     case wireless
80 99
 
@@ -99,6 +118,132 @@ enum ChargingTransportMode: String, CaseIterable, Identifiable {
99 118
     }
100 119
 }
101 120
 
121
+enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
122
+    case on
123
+    case off
124
+
125
+    var id: String { rawValue }
126
+
127
+    var title: String {
128
+        switch self {
129
+        case .on:
130
+            return "On"
131
+        case .off:
132
+            return "Off"
133
+        }
134
+    }
135
+
136
+    var description: String {
137
+        switch self {
138
+        case .on:
139
+            return "Device stays powered on while charging."
140
+        case .off:
141
+            return "Device is powered off while charging."
142
+        }
143
+    }
144
+}
145
+
146
+enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
147
+    case onOnly
148
+    case onOrOff
149
+    case offOnly
150
+
151
+    var id: String { rawValue }
152
+
153
+    var title: String {
154
+        switch self {
155
+        case .onOnly:
156
+            return "On Only"
157
+        case .onOrOff:
158
+            return "On or Off"
159
+        case .offOnly:
160
+            return "Off Only"
161
+        }
162
+    }
163
+
164
+    var description: String {
165
+        switch self {
166
+        case .onOnly:
167
+            return "The device can be recorded only while it is powered on."
168
+        case .onOrOff:
169
+            return "The session must specify whether the device is on or off."
170
+        case .offOnly:
171
+            return "The device can be recorded only while it is powered off."
172
+        }
173
+    }
174
+
175
+    var supportedModes: [ChargingStateMode] {
176
+        switch self {
177
+        case .onOnly:
178
+            return [.on]
179
+        case .onOrOff:
180
+            return [.on, .off]
181
+        case .offOnly:
182
+            return [.off]
183
+        }
184
+    }
185
+
186
+    var supportsMultipleModes: Bool {
187
+        supportedModes.count > 1
188
+    }
189
+
190
+    var supportsChargingWhileOff: Bool {
191
+        self != .onOnly
192
+    }
193
+
194
+    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
195
+        supportsChargingWhileOff ? .onOrOff : .onOnly
196
+    }
197
+}
198
+
199
+enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
200
+    case wiredOn
201
+    case wiredOff
202
+    case wirelessOn
203
+    case wirelessOff
204
+
205
+    var id: String { rawValue }
206
+
207
+    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
208
+        switch (chargingTransportMode, chargingStateMode) {
209
+        case (.wired, .on):
210
+            self = .wiredOn
211
+        case (.wired, .off):
212
+            self = .wiredOff
213
+        case (.wireless, .on):
214
+            self = .wirelessOn
215
+        case (.wireless, .off):
216
+            self = .wirelessOff
217
+        }
218
+    }
219
+
220
+    var chargingTransportMode: ChargingTransportMode {
221
+        switch self {
222
+        case .wiredOn, .wiredOff:
223
+            return .wired
224
+        case .wirelessOn, .wirelessOff:
225
+            return .wireless
226
+        }
227
+    }
228
+
229
+    var chargingStateMode: ChargingStateMode {
230
+        switch self {
231
+        case .wiredOn, .wirelessOn:
232
+            return .on
233
+        case .wiredOff, .wirelessOff:
234
+            return .off
235
+        }
236
+    }
237
+
238
+    var title: String {
239
+        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
240
+    }
241
+
242
+    var shortTitle: String {
243
+        "\(chargingTransportMode.title) \(chargingStateMode.title)"
244
+    }
245
+}
246
+
102 247
 enum WirelessChargingProfile: String, CaseIterable, Identifiable {
103 248
     case magsafe
104 249
     case genericQi
@@ -164,9 +309,12 @@ struct ChargeSessionSummary: Identifiable, Hashable {
164 309
     let startedAt: Date
165 310
     let endedAt: Date?
166 311
     let lastObservedAt: Date
312
+    let pausedAt: Date?
167 313
     let status: ChargeSessionStatus
168 314
     let sourceMode: ChargeSessionSourceMode
169 315
     let chargingTransportMode: ChargingTransportMode
316
+    let chargingStateMode: ChargingStateMode
317
+    let autoStopEnabled: Bool
170 318
     let measuredEnergyWh: Double
171 319
     let effectiveBatteryEnergyWh: Double?
172 320
     let measuredChargeAh: Double
@@ -194,6 +342,13 @@ struct ChargeSessionSummary: Identifiable, Hashable {
194 342
     let checkpoints: [ChargeCheckpointSummary]
195 343
     let aggregatedSamples: [ChargeSessionSampleSummary]
196 344
 
345
+    var sessionKind: ChargeSessionKind {
346
+        ChargeSessionKind(
347
+            chargingTransportMode: chargingTransportMode,
348
+            chargingStateMode: chargingStateMode
349
+        )
350
+    }
351
+
197 352
     var duration: TimeInterval {
198 353
         (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
199 354
     }
@@ -206,6 +361,18 @@ struct ChargeSessionSummary: Identifiable, Hashable {
206 361
         guard let startBatteryPercent, let endBatteryPercent else { return nil }
207 362
         return endBatteryPercent - startBatteryPercent
208 363
     }
364
+
365
+    var canAutoStop: Bool {
366
+        autoStopEnabled && stopThresholdAmps > 0
367
+    }
368
+
369
+    var isPaused: Bool {
370
+        status == .paused
371
+    }
372
+
373
+    var isOpen: Bool {
374
+        status.isOpen
375
+    }
209 376
 }
210 377
 
211 378
 struct BatteryLevelPrediction: Hashable {
@@ -240,10 +407,13 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
240 407
     let name: String
241 408
     let deviceClass: ChargedDeviceClass
242 409
     let supportsChargingWhileOff: Bool
410
+    let chargingStateAvailability: ChargingStateAvailability
243 411
     let supportsWiredCharging: Bool
244 412
     let supportsWirelessCharging: Bool
245 413
     let preferredChargingTransportMode: ChargingTransportMode
246 414
     let wirelessChargingProfile: WirelessChargingProfile
415
+    let configuredCompletionCurrents: [ChargeSessionKind: Double]
416
+    let learnedCompletionCurrents: [ChargeSessionKind: Double]
247 417
     let wirelessChargerEfficiencyFactor: Double?
248 418
     let wiredChargeCompletionCurrentAmps: Double?
249 419
     let wirelessChargeCompletionCurrentAmps: Double?
@@ -270,7 +440,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
270 440
     }
271 441
 
272 442
     var activeSession: ChargeSessionSummary? {
273
-        sessions.first(where: { $0.status == .active })
443
+        sessions.first(where: \.isOpen)
274 444
     }
275 445
 
276 446
     var recentCompletedSessions: [ChargeSessionSummary] {
@@ -292,6 +462,29 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
292 462
         return modes.isEmpty ? [preferredChargingTransportMode] : modes
293 463
     }
294 464
 
465
+    var supportedChargingStateModes: [ChargingStateMode] {
466
+        chargingStateAvailability.supportedModes
467
+    }
468
+
469
+    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
470
+        if let matchingSession = sessions.first(where: {
471
+            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
472
+        }) {
473
+            return matchingSession.chargingStateMode
474
+        }
475
+        return chargingStateAvailability.supportedModes.first ?? .on
476
+    }
477
+
478
+    func sessionKind(
479
+        for chargingTransportMode: ChargingTransportMode,
480
+        chargingStateMode: ChargingStateMode? = nil
481
+    ) -> ChargeSessionKind {
482
+        ChargeSessionKind(
483
+            chargingTransportMode: chargingTransportMode,
484
+            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
485
+        )
486
+    }
487
+
295 488
     func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
296 489
         switch chargingTransportMode {
297 490
         case .wired:
@@ -310,8 +503,12 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
310 503
         }
311 504
     }
312 505
 
313
-    func configuredCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
314
-        switch chargingTransportMode {
506
+    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
507
+        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
508
+            return explicitCurrent
509
+        }
510
+
511
+        switch sessionKind.chargingTransportMode {
315 512
         case .wired:
316 513
             return wiredChargeCompletionCurrentAmps
317 514
         case .wireless:
@@ -319,8 +516,30 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
319 516
         }
320 517
     }
321 518
 
322
-    func resolvedCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
323
-        configuredCompletionCurrentAmps(for: chargingTransportMode)
519
+    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
520
+        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
521
+            return learnedCurrent
522
+        }
523
+
524
+        switch sessionKind.chargingTransportMode {
525
+        case .wired:
526
+            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
527
+        case .wireless:
528
+            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
529
+        }
530
+    }
531
+
532
+    func resolvedCompletionCurrentAmps(
533
+        for chargingTransportMode: ChargingTransportMode,
534
+        chargingStateMode: ChargingStateMode? = nil
535
+    ) -> Double? {
536
+        let sessionKind = sessionKind(
537
+            for: chargingTransportMode,
538
+            chargingStateMode: chargingStateMode
539
+        )
540
+
541
+        return configuredCompletionCurrentAmps(for: sessionKind)
542
+            ?? learnedCompletionCurrentAmps(for: sessionKind)
324 543
             ?? minimumCurrentAmps(for: chargingTransportMode)
325 544
             ?? minimumCurrentAmps
326 545
     }
+649 -119
USB Meter/Model/ChargeInsightsStore.swift
@@ -39,6 +39,7 @@ final class ChargeInsightsStore {
39 39
     private let activeSessionSaveInterval: TimeInterval = 15
40 40
     private let counterDecreaseTolerance = 0.002
41 41
     private let completionConfirmationCooldown: TimeInterval = 15 * 60
42
+    private let pausedSessionTimeout: TimeInterval = 10 * 60
42 43
     private let defaultCompletionPercentThreshold = 95.0
43 44
     private let completionContradictionTolerancePercent = 2.0
44 45
     private let minimumWirelessEfficiencyFactor = 0.35
@@ -69,13 +70,12 @@ final class ChargeInsightsStore {
69 70
     func createChargedDevice(
70 71
         name: String,
71 72
         deviceClass: ChargedDeviceClass,
72
-        supportsChargingWhileOff: Bool,
73
+        chargingStateAvailability: ChargingStateAvailability,
73 74
         supportsWiredCharging: Bool,
74 75
         supportsWirelessCharging: Bool,
75 76
         preferredChargingTransportMode: ChargingTransportMode,
76 77
         wirelessChargingProfile: WirelessChargingProfile,
77
-        wiredChargeCompletionCurrentAmps: Double?,
78
-        wirelessChargeCompletionCurrentAmps: Double?,
78
+        configuredCompletionCurrents: [ChargeSessionKind: Double],
79 79
         notes: String?,
80 80
         assignTo meterMACAddress: String?
81 81
     ) -> Bool {
@@ -94,7 +94,8 @@ final class ChargeInsightsStore {
94 94
             object.setValue(UUID().uuidString, forKey: "id")
95 95
             object.setValue(normalizedName, forKey: "name")
96 96
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
97
-            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
97
+            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
98
+            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
98 99
             object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
99 100
             object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
100 101
             object.setValue(
@@ -106,8 +107,9 @@ final class ChargeInsightsStore {
106 107
                 forKey: "preferredChargingTransportRawValue"
107 108
             )
108 109
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
109
-            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
110
-            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
110
+            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
111
+            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
112
+            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
111 113
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
112 114
             object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
113 115
             object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
@@ -123,13 +125,12 @@ final class ChargeInsightsStore {
123 125
         id: UUID,
124 126
         name: String,
125 127
         deviceClass: ChargedDeviceClass,
126
-        supportsChargingWhileOff: Bool,
128
+        chargingStateAvailability: ChargingStateAvailability,
127 129
         supportsWiredCharging: Bool,
128 130
         supportsWirelessCharging: Bool,
129 131
         preferredChargingTransportMode: ChargingTransportMode,
130 132
         wirelessChargingProfile: WirelessChargingProfile,
131
-        wiredChargeCompletionCurrentAmps: Double?,
132
-        wirelessChargeCompletionCurrentAmps: Double?,
133
+        configuredCompletionCurrents: [ChargeSessionKind: Double],
133 134
         notes: String?
134 135
     ) -> Bool {
135 136
         let normalizedName = normalizedText(name)
@@ -143,6 +144,7 @@ final class ChargeInsightsStore {
143 144
             }
144 145
 
145 146
             let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
147
+            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
146 148
             let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
147 149
             let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
148 150
             let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
@@ -155,18 +157,22 @@ final class ChargeInsightsStore {
155 157
 
156 158
             object.setValue(normalizedName, forKey: "name")
157 159
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
158
-            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
160
+            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
161
+            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
159 162
             object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
160 163
             object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
161 164
             object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
162 165
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
163
-            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
164
-            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
166
+            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
167
+            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
168
+            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
165 169
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
166 170
             object.setValue(now, forKey: "updatedAt")
167 171
 
172
+            let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
168 173
             let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
169 174
             let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
175
+                || previousChargingStateAvailability != chargingStateAvailability
170 176
                 || previousSupportsWiredCharging != supportsWiredCharging
171 177
                 || previousSupportsWirelessCharging != supportsWirelessCharging
172 178
                 || previousPreferredChargingTransportMode != resolvedPreferredTransportMode
@@ -174,7 +180,7 @@ final class ChargeInsightsStore {
174 180
             if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
175 181
                 let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
176 182
                 for session in sessions {
177
-                    let isActive = statusValue(session, key: "statusRawValue") == .active
183
+                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
178 184
 
179 185
                     if shouldRecalculateSessionCapacity {
180 186
                         session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
@@ -182,7 +188,7 @@ final class ChargeInsightsStore {
182 188
                         session.setValue(now, forKey: "updatedAt")
183 189
                     }
184 190
 
185
-                    guard isActive, shouldRefreshActiveSessions else {
191
+                    guard isOpen, shouldRefreshActiveSessions else {
186 192
                         continue
187 193
                     }
188 194
 
@@ -191,16 +197,23 @@ final class ChargeInsightsStore {
191 197
                         supportsWiredCharging: supportsWiredCharging,
192 198
                         supportsWirelessCharging: supportsWirelessCharging
193 199
                     )
194
-                    let fallbackStopThreshold = max(optionalDoubleValue(session, key: "stopThresholdAmps") ?? 0.01, 0.01)
200
+                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
201
+                        chargingStateMode(for: session),
202
+                        availability: chargingStateAvailability
203
+                    )
204
+                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
195 205
 
196 206
                     session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
197 207
                     session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
208
+                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
198 209
                     session.setValue(
199 210
                         resolvedStopThreshold(
200 211
                             for: object,
201 212
                             chargingTransportMode: resolvedSessionChargingTransportMode,
202
-                            fallback: fallbackStopThreshold
203
-                        ),
213
+                            chargingStateMode: resolvedSessionChargingStateMode,
214
+                            charger: charger,
215
+                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
216
+                        ) ?? 0,
204 217
                         forKey: "stopThresholdAmps"
205 218
                     )
206 219
                     session.setValue(now, forKey: "updatedAt")
@@ -264,10 +277,10 @@ final class ChargeInsightsStore {
264 277
             object.setValue(Date(), forKey: "updatedAt")
265 278
 
266 279
             if kind == .charger,
267
-               let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
268
-               chargingTransportMode(for: activeSession) == .wireless {
269
-                activeSession.setValue(id.uuidString, forKey: "chargerID")
270
-                activeSession.setValue(Date(), forKey: "updatedAt")
280
+               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
281
+               chargingTransportMode(for: openSession) == .wireless {
282
+                openSession.setValue(id.uuidString, forKey: "chargerID")
283
+                openSession.setValue(Date(), forKey: "updatedAt")
271 284
             }
272 285
 
273 286
             didSave = saveContext()
@@ -282,8 +295,8 @@ final class ChargeInsightsStore {
282 295
 
283 296
         var didSave = false
284 297
         context.performAndWait {
285
-            let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC)
286
-            let device = (activeSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
298
+            let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC)
299
+            let device = (openSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
287 300
                 ?? resolvedDeviceObject(for: normalizedMAC)
288 301
 
289 302
             guard let device else {
@@ -303,10 +316,24 @@ final class ChargeInsightsStore {
303 316
             device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
304 317
             device.setValue(Date(), forKey: "updatedAt")
305 318
 
306
-            if let activeSession {
307
-                activeSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
308
-                activeSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
309
-                activeSession.setValue(Date(), forKey: "updatedAt")
319
+            if let openSession {
320
+                let chargingStateMode = resolvedChargingStateMode(
321
+                    chargingStateMode(for: openSession),
322
+                    availability: chargingStateAvailability(for: device)
323
+                )
324
+                openSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
325
+                openSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
326
+                openSession.setValue(
327
+                    resolvedStopThreshold(
328
+                        for: device,
329
+                        chargingTransportMode: resolvedMode,
330
+                        chargingStateMode: chargingStateMode,
331
+                        charger: charger,
332
+                        fallback: optionalDoubleValue(openSession, key: "stopThresholdAmps")
333
+                    ) ?? 0,
334
+                    forKey: "stopThresholdAmps"
335
+                )
336
+                openSession.setValue(Date(), forKey: "updatedAt")
310 337
             }
311 338
 
312 339
             didSave = saveContext()
@@ -316,46 +343,198 @@ final class ChargeInsightsStore {
316 343
     }
317 344
 
318 345
     @discardableResult
319
-    func ensureSession(for snapshot: ChargingMonitorSnapshot, forceStart: Bool) -> Bool {
346
+    func startSession(
347
+        for snapshot: ChargingMonitorSnapshot,
348
+        chargedDeviceID: UUID,
349
+        chargerID: UUID?,
350
+        chargingTransportMode: ChargingTransportMode,
351
+        chargingStateMode: ChargingStateMode,
352
+        autoStopEnabled: Bool,
353
+        initialBatteryPercent: Double
354
+    ) -> Bool {
355
+        guard initialBatteryPercent.isFinite, initialBatteryPercent >= 0, initialBatteryPercent <= 100 else {
356
+            return false
357
+        }
358
+
320 359
         var didSave = false
321 360
         context.performAndWait {
322
-            guard let resolved = resolvedDeviceObject(for: snapshot.meterMACAddress) else {
361
+            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
323 362
                 return
324 363
             }
325 364
 
326
-            if fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress) != nil {
327
-                didSave = false
365
+            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
328 366
                 return
329 367
             }
330 368
 
331
-            let chargingTransportMode = preferredChargingTransportMode(for: resolved)
332
-            let charger = chargingTransportMode == .wireless
333
-                ? resolvedChargerObject(for: snapshot.meterMACAddress)
334
-                : nil
335
-            guard chargingTransportMode == .wired || charger != nil else {
336
-                return
337
-            }
338
-            let stopThreshold = resolvedStopThreshold(
339
-                for: resolved,
340
-                chargingTransportMode: chargingTransportMode,
341
-                fallback: snapshot.fallbackStopThresholdAmps
369
+            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
370
+                chargingTransportMode,
371
+                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
372
+                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
342 373
             )
343
-            guard forceStart || snapshot.currentAmps > stopThreshold else {
374
+            let resolvedChargingStateMode = resolvedChargingStateMode(
375
+                chargingStateMode,
376
+                availability: chargingStateAvailability(for: chargedDevice)
377
+            )
378
+            let charger = resolvedChargingTransportMode == .wireless
379
+                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
380
+                : nil
381
+            guard resolvedChargingTransportMode == .wired || charger != nil else {
344 382
                 return
345 383
             }
346
-
347
-            _ = createSessionObject(
348
-                for: resolved,
384
+            let stopThreshold = autoStopEnabled ? resolvedStopThreshold(
385
+                for: chargedDevice,
386
+                chargingTransportMode: resolvedChargingTransportMode,
387
+                chargingStateMode: resolvedChargingStateMode,
388
+                charger: charger,
389
+                fallback: nil
390
+            ) : nil
391
+            guard let session = createSessionObject(
392
+                for: chargedDevice,
349 393
                 charger: charger,
350 394
                 snapshot: snapshot,
351 395
                 stopThreshold: stopThreshold,
352
-                chargingTransportMode: chargingTransportMode
353
-            )
396
+                chargingTransportMode: resolvedChargingTransportMode,
397
+                chargingStateMode: resolvedChargingStateMode,
398
+                autoStopEnabled: autoStopEnabled
399
+            ) else {
400
+                return
401
+            }
402
+
403
+            guard insertBatteryCheckpoint(
404
+                percent: initialBatteryPercent,
405
+                label: "Start",
406
+                timestamp: snapshot.observedAt,
407
+                to: session
408
+            ) != nil else {
409
+                return
410
+            }
354 411
             didSave = saveContext()
355 412
         }
356 413
         return didSave
357 414
     }
358 415
 
416
+    @discardableResult
417
+    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
418
+        var didSave = false
419
+        context.performAndWait {
420
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
421
+                return
422
+            }
423
+
424
+            guard statusValue(session, key: "statusRawValue") == .active else {
425
+                return
426
+            }
427
+
428
+            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
429
+            session.setValue(observedAt, forKey: "pausedAt")
430
+            session.setValue(nil, forKey: "belowThresholdSince")
431
+            clearCompletionConfirmationState(for: session)
432
+            session.setValue(observedAt, forKey: "updatedAt")
433
+            didSave = saveContext()
434
+        }
435
+        return didSave
436
+    }
437
+
438
+    @discardableResult
439
+    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
440
+        var didSave = false
441
+        context.performAndWait {
442
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
443
+                return
444
+            }
445
+
446
+            guard statusValue(session, key: "statusRawValue") == .paused else {
447
+                return
448
+            }
449
+
450
+            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
451
+            let resumedAt = snapshot?.observedAt ?? Date()
452
+            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
453
+                finishSession(
454
+                    session,
455
+                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
456
+                    finalBatteryPercent: nil,
457
+                    label: nil,
458
+                    status: .completed
459
+                )
460
+                guard saveContext() else {
461
+                    return
462
+                }
463
+                if let deviceID = stringValue(session, key: "chargedDeviceID") {
464
+                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
465
+                    didSave = saveContext()
466
+                } else {
467
+                    didSave = true
468
+                }
469
+                return
470
+            }
471
+
472
+            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
473
+            session.setValue(nil, forKey: "pausedAt")
474
+            session.setValue(nil, forKey: "belowThresholdSince")
475
+            clearCompletionConfirmationState(for: session)
476
+            session.setValue(resumedAt, forKey: "lastObservedAt")
477
+            if let snapshot {
478
+                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
479
+                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
480
+                session.setValue(
481
+                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
482
+                    forKey: "lastObservedVoltageVolts"
483
+                )
484
+            } else {
485
+                session.setValue(0, forKey: "lastObservedCurrentAmps")
486
+                session.setValue(0, forKey: "lastObservedPowerWatts")
487
+                session.setValue(nil, forKey: "lastObservedVoltageVolts")
488
+            }
489
+            session.setValue(resumedAt, forKey: "updatedAt")
490
+            didSave = saveContext()
491
+        }
492
+        return didSave
493
+    }
494
+
495
+    @discardableResult
496
+    func stopSession(
497
+        id sessionID: UUID,
498
+        finalBatteryPercent: Double,
499
+        label: String?
500
+    ) -> Bool {
501
+        guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
502
+            return false
503
+        }
504
+
505
+        var didSave = false
506
+        context.performAndWait {
507
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
508
+                return
509
+            }
510
+
511
+            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
512
+                return
513
+            }
514
+
515
+            let observedAt = snapshotDateForManualStop(session)
516
+            finishSession(
517
+                session,
518
+                observedAt: observedAt,
519
+                finalBatteryPercent: finalBatteryPercent,
520
+                label: label,
521
+                status: .completed
522
+            )
523
+
524
+            guard saveContext() else {
525
+                return
526
+            }
527
+
528
+            if let deviceID = stringValue(session, key: "chargedDeviceID") {
529
+                refreshDerivedMetrics(forChargedDeviceID: deviceID)
530
+                didSave = saveContext()
531
+            } else {
532
+                didSave = true
533
+            }
534
+        }
535
+        return didSave
536
+    }
537
+
359 538
     @discardableResult
360 539
     func addBatteryCheckpoint(
361 540
         percent: Double,
@@ -368,10 +547,7 @@ final class ChargeInsightsStore {
368 547
 
369 548
         var didSave = false
370 549
         context.performAndWait {
371
-            guard
372
-                let session = fetchActiveSessionObject(forMeterMACAddress: meterMACAddress)
373
-                    ?? fetchLatestSessionObject(forMeterMACAddress: meterMACAddress)
374
-            else {
550
+            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
375 551
                 return
376 552
             }
377 553
 
@@ -433,13 +609,13 @@ final class ChargeInsightsStore {
433 609
                 return
434 610
             }
435 611
 
436
-            let endedAt = dateValue(session, key: "lastObservedAt") ?? Date()
437
-            session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
438
-            session.setValue(endedAt, forKey: "endedAt")
439
-            session.setValue(optionalDoubleValue(session, key: "lastObservedCurrentAmps"), forKey: "completionCurrentAmps")
440
-            clearCompletionConfirmationState(for: session)
441
-            updateCapacityEstimate(for: session)
442
-            session.setValue(Date(), forKey: "updatedAt")
612
+            finishSession(
613
+                session,
614
+                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
615
+                finalBatteryPercent: nil,
616
+                label: nil,
617
+                status: .completed
618
+            )
443 619
 
444 620
             if saveContext() {
445 621
                 if let deviceID = stringValue(session, key: "chargedDeviceID") {
@@ -564,20 +740,22 @@ final class ChargeInsightsStore {
564 740
         var didSave = false
565 741
 
566 742
         context.performAndWait {
567
-            let activeSession = fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress)
568
-            let resolvedDevice = activeSession.flatMap {
569
-                stringValue($0, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:))
570
-            } ?? resolvedDeviceObject(for: snapshot.meterMACAddress)
743
+            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
744
+                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
745
+                return
746
+            }
571 747
 
572
-            guard let resolvedDevice else {
748
+            if statusValue(session, key: "statusRawValue") == .paused {
749
+                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
750
+                    didSave = true
751
+                }
573 752
                 return
574 753
             }
575 754
 
576
-            let chargingTransportMode = activeSession.map { self.chargingTransportMode(for: $0) }
577
-                ?? preferredChargingTransportMode(for: resolvedDevice)
755
+            let chargingTransportMode = self.chargingTransportMode(for: session)
756
+            let chargingStateMode = self.chargingStateMode(for: session)
578 757
             let charger = chargingTransportMode == .wireless
579
-                ? (activeSession.flatMap { stringValue($0, key: "chargerID") }.flatMap(fetchChargedDeviceObject(id:))
580
-                    ?? resolvedChargerObject(for: snapshot.meterMACAddress))
758
+                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
581 759
                 : nil
582 760
             guard chargingTransportMode == .wired || charger != nil else {
583 761
                 return
@@ -585,26 +763,12 @@ final class ChargeInsightsStore {
585 763
             let stopThreshold = resolvedStopThreshold(
586 764
                 for: resolvedDevice,
587 765
                 chargingTransportMode: chargingTransportMode,
588
-                fallback: snapshot.fallbackStopThresholdAmps
766
+                chargingStateMode: chargingStateMode,
767
+                charger: charger,
768
+                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
589 769
             )
590
-            let session = activeSession ?? {
591
-                guard snapshot.currentAmps > stopThreshold else {
592
-                    return nil
593
-                }
594
-                return createSessionObject(
595
-                    for: resolvedDevice,
596
-                    charger: charger,
597
-                    snapshot: snapshot,
598
-                    stopThreshold: stopThreshold,
599
-                    chargingTransportMode: chargingTransportMode
600
-                )
601
-            }()
602 770
 
603
-            guard let session else {
604
-                return
605
-            }
606
-
607
-            update(session: session, with: snapshot, stopThreshold: stopThreshold)
771
+            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
608 772
             updateAggregatedSample(session: session, with: snapshot)
609 773
 
610 774
             let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
@@ -667,10 +831,16 @@ final class ChargeInsightsStore {
667 831
                         )
668 832
                     }
669 833
                     .sorted { lhs, rhs in
670
-                        if lhs.status == .active && rhs.status != .active {
834
+                        if lhs.status.isOpen && !rhs.status.isOpen {
671 835
                             return true
672 836
                         }
673
-                        if lhs.status != .active && rhs.status == .active {
837
+                        if !lhs.status.isOpen && rhs.status.isOpen {
838
+                            return false
839
+                        }
840
+                        if lhs.status == .active && rhs.status == .paused {
841
+                            return true
842
+                        }
843
+                        if lhs.status == .paused && rhs.status == .active {
674 844
                             return false
675 845
                         }
676 846
                         return lhs.startedAt > rhs.startedAt
@@ -682,10 +852,13 @@ final class ChargeInsightsStore {
682 852
                     name: name,
683 853
                     deviceClass: deviceClass,
684 854
                     supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
855
+                    chargingStateAvailability: chargingStateAvailability(for: device),
685 856
                     supportsWiredCharging: supportsWiredCharging(for: device),
686 857
                     supportsWirelessCharging: supportsWirelessCharging(for: device),
687 858
                     preferredChargingTransportMode: preferredChargingTransportMode(for: device),
688 859
                     wirelessChargingProfile: wirelessChargingProfile(for: device),
860
+                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
861
+                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
689 862
                     wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
690 863
                     wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
691 864
                     wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
@@ -747,7 +920,7 @@ final class ChargeInsightsStore {
747 920
         return fetchChargedDeviceSummaries()
748 921
             .flatMap(\.sessions)
749 922
             .first(where: {
750
-                $0.status == .active && $0.meterMACAddress == normalizedMAC
923
+                $0.status.isOpen && $0.meterMACAddress == normalizedMAC
751 924
             })
752 925
     }
753 926
 
@@ -755,8 +928,10 @@ final class ChargeInsightsStore {
755 928
         for chargedDevice: NSManagedObject,
756 929
         charger: NSManagedObject?,
757 930
         snapshot: ChargingMonitorSnapshot,
758
-        stopThreshold: Double,
759
-        chargingTransportMode: ChargingTransportMode
931
+        stopThreshold: Double?,
932
+        chargingTransportMode: ChargingTransportMode,
933
+        chargingStateMode: ChargingStateMode,
934
+        autoStopEnabled: Bool
760 935
     ) -> NSManagedObject? {
761 936
         guard
762 937
             let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
@@ -778,7 +953,9 @@ final class ChargeInsightsStore {
778 953
         session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
779 954
         session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
780 955
         session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
781
-        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
956
+        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
957
+        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
958
+        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
782 959
         session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
783 960
         session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
784 961
         session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
@@ -786,6 +963,15 @@ final class ChargeInsightsStore {
786 963
             chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
787 964
             forKey: "lastObservedVoltageVolts"
788 965
         )
966
+        session.setValue(
967
+            hasObservedChargeFlow(
968
+                currentAmps: snapshot.currentAmps,
969
+                chargingTransportMode: chargingTransportMode,
970
+                charger: charger,
971
+                stopThreshold: stopThreshold
972
+            ),
973
+            forKey: "hasObservedChargeFlow"
974
+        )
789 975
         session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
790 976
         session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
791 977
         session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
@@ -817,7 +1003,8 @@ final class ChargeInsightsStore {
817 1003
     private func update(
818 1004
         session: NSManagedObject,
819 1005
         with snapshot: ChargingMonitorSnapshot,
820
-        stopThreshold: Double
1006
+        stopThreshold: Double?,
1007
+        charger: NSManagedObject?
821 1008
     ) {
822 1009
         let sessionChargingTransportMode = chargingTransportMode(for: session)
823 1010
         let lastObservedAt = dateValue(session, key: "lastObservedAt")
@@ -903,10 +1090,23 @@ final class ChargeInsightsStore {
903 1090
             updatedMinimum = existingMinimum ?? 0
904 1091
         }
905 1092
 
1093
+        let effectiveCurrent = effectiveCurrentAmps(
1094
+            fromMeasuredCurrent: snapshot.currentAmps,
1095
+            chargingTransportMode: sessionChargingTransportMode,
1096
+            charger: charger
1097
+        )
1098
+        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1099
+            || hasObservedChargeFlow(
1100
+                currentAmps: snapshot.currentAmps,
1101
+                chargingTransportMode: sessionChargingTransportMode,
1102
+                charger: charger,
1103
+                stopThreshold: stopThreshold
1104
+            )
1105
+
906 1106
         session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
907 1107
         session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
908 1108
         session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
909
-        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
1109
+        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
910 1110
         session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
911 1111
         session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
912 1112
         session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
@@ -914,6 +1114,7 @@ final class ChargeInsightsStore {
914 1114
             sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
915 1115
             forKey: "lastObservedVoltageVolts"
916 1116
         )
1117
+        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
917 1118
         session.setValue(
918 1119
             max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
919 1120
             forKey: "maximumObservedCurrentAmps"
@@ -932,7 +1133,14 @@ final class ChargeInsightsStore {
932 1133
         session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
933 1134
         maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
934 1135
 
935
-        if snapshot.currentAmps <= stopThreshold {
1136
+        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1137
+            session.setValue(nil, forKey: "belowThresholdSince")
1138
+            clearCompletionConfirmationState(for: session)
1139
+            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1140
+            return
1141
+        }
1142
+
1143
+        if effectiveCurrent <= stopThreshold {
936 1144
             let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
937 1145
             session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
938 1146
             if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
@@ -944,10 +1152,13 @@ final class ChargeInsightsStore {
944 1152
                 if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
945 1153
                     requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
946 1154
                 } else {
947
-                    session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
948
-                    session.setValue(snapshot.observedAt, forKey: "endedAt")
949
-                    session.setValue(snapshot.currentAmps, forKey: "completionCurrentAmps")
950
-                    updateCapacityEstimate(for: session)
1155
+                    finishSession(
1156
+                        session,
1157
+                        observedAt: snapshot.observedAt,
1158
+                        finalBatteryPercent: nil,
1159
+                        label: nil,
1160
+                        status: .completed
1161
+                    )
951 1162
                 }
952 1163
             }
953 1164
         } else {
@@ -1074,6 +1285,102 @@ final class ChargeInsightsStore {
1074 1285
         session.setValue(nil, forKey: "completionContradictionPercent")
1075 1286
     }
1076 1287
 
1288
+    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1289
+        if statusValue(session, key: "statusRawValue") == .paused {
1290
+            return dateValue(session, key: "pausedAt")
1291
+                ?? dateValue(session, key: "lastObservedAt")
1292
+                ?? Date()
1293
+        }
1294
+        return dateValue(session, key: "lastObservedAt") ?? Date()
1295
+    }
1296
+
1297
+    @discardableResult
1298
+    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1299
+        guard statusValue(session, key: "statusRawValue") == .paused else {
1300
+            return false
1301
+        }
1302
+
1303
+        guard let pausedAt = dateValue(session, key: "pausedAt"),
1304
+              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1305
+            return false
1306
+        }
1307
+
1308
+        finishSession(
1309
+            session,
1310
+            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1311
+            finalBatteryPercent: nil,
1312
+            label: nil,
1313
+            status: .completed
1314
+        )
1315
+
1316
+        guard saveContext() else {
1317
+            return false
1318
+        }
1319
+
1320
+        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1321
+            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1322
+            return saveContext()
1323
+        }
1324
+
1325
+        return true
1326
+    }
1327
+
1328
+    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1329
+        let chargingTransportMode = chargingTransportMode(for: session)
1330
+        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1331
+            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1332
+
1333
+        guard measuredCurrent > 0 else {
1334
+            return nil
1335
+        }
1336
+
1337
+        let charger = chargingTransportMode == .wireless
1338
+            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1339
+            : nil
1340
+
1341
+        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1342
+            return nil
1343
+        }
1344
+
1345
+        let effectiveCurrent = effectiveCurrentAmps(
1346
+            fromMeasuredCurrent: measuredCurrent,
1347
+            chargingTransportMode: chargingTransportMode,
1348
+            charger: charger
1349
+        )
1350
+        guard effectiveCurrent > 0 else {
1351
+            return nil
1352
+        }
1353
+        return effectiveCurrent
1354
+    }
1355
+
1356
+    private func finishSession(
1357
+        _ session: NSManagedObject,
1358
+        observedAt: Date,
1359
+        finalBatteryPercent: Double?,
1360
+        label: String?,
1361
+        status: ChargeSessionStatus
1362
+    ) {
1363
+        if let finalBatteryPercent {
1364
+            _ = insertBatteryCheckpoint(
1365
+                percent: finalBatteryPercent,
1366
+                label: label,
1367
+                timestamp: observedAt,
1368
+                to: session
1369
+            )
1370
+        }
1371
+
1372
+        session.setValue(status.rawValue, forKey: "statusRawValue")
1373
+        session.setValue(nil, forKey: "pausedAt")
1374
+        session.setValue(nil, forKey: "belowThresholdSince")
1375
+        session.setValue(observedAt, forKey: "endedAt")
1376
+        session.setValue(observedAt, forKey: "lastObservedAt")
1377
+        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1378
+        clearCompletionConfirmationState(for: session)
1379
+        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1380
+        updateCapacityEstimate(for: session)
1381
+        session.setValue(observedAt, forKey: "updatedAt")
1382
+    }
1383
+
1077 1384
     private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1078 1385
         guard
1079 1386
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
@@ -1192,17 +1499,18 @@ final class ChargeInsightsStore {
1192 1499
     }
1193 1500
 
1194 1501
     @discardableResult
1195
-    private func addBatteryCheckpoint(
1502
+    private func insertBatteryCheckpoint(
1196 1503
         percent: Double,
1197 1504
         label: String?,
1505
+        timestamp: Date = Date(),
1198 1506
         to session: NSManagedObject
1199
-    ) -> Bool {
1507
+    ) -> String? {
1200 1508
         guard
1201 1509
             let sessionID = stringValue(session, key: "id"),
1202 1510
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1203 1511
             let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1204 1512
         else {
1205
-            return false
1513
+            return nil
1206 1514
         }
1207 1515
 
1208 1516
         let checkpoint = NSManagedObject(entity: entity, insertInto: context)
@@ -1211,7 +1519,7 @@ final class ChargeInsightsStore {
1211 1519
         checkpoint.setValue(UUID().uuidString, forKey: "id")
1212 1520
         checkpoint.setValue(sessionID, forKey: "sessionID")
1213 1521
         checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1214
-        checkpoint.setValue(Date(), forKey: "timestamp")
1522
+        checkpoint.setValue(timestamp, forKey: "timestamp")
1215 1523
         checkpoint.setValue(percent, forKey: "batteryPercent")
1216 1524
         checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1217 1525
         checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
@@ -1221,15 +1529,34 @@ final class ChargeInsightsStore {
1221 1529
             forKey: "voltageVolts"
1222 1530
         )
1223 1531
         checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
1224
-        checkpoint.setValue(Date(), forKey: "createdAt")
1532
+        checkpoint.setValue(timestamp, forKey: "createdAt")
1225 1533
 
1226 1534
         if session.value(forKey: "startBatteryPercent") == nil {
1227 1535
             session.setValue(percent, forKey: "startBatteryPercent")
1228 1536
         }
1229 1537
         session.setValue(percent, forKey: "endBatteryPercent")
1230
-        session.setValue(Date(), forKey: "updatedAt")
1538
+        session.setValue(timestamp, forKey: "updatedAt")
1231 1539
         updateCapacityEstimate(for: session)
1232 1540
 
1541
+        return chargedDeviceID
1542
+    }
1543
+
1544
+    @discardableResult
1545
+    private func addBatteryCheckpoint(
1546
+        percent: Double,
1547
+        label: String?,
1548
+        to session: NSManagedObject,
1549
+        timestamp: Date = Date()
1550
+    ) -> Bool {
1551
+        guard let chargedDeviceID = insertBatteryCheckpoint(
1552
+            percent: percent,
1553
+            label: label,
1554
+            timestamp: timestamp,
1555
+            to: session
1556
+        ) else {
1557
+            return false
1558
+        }
1559
+
1233 1560
         guard saveContext() else {
1234 1561
             return false
1235 1562
         }
@@ -1299,6 +1626,7 @@ final class ChargeInsightsStore {
1299 1626
         }
1300 1627
 
1301 1628
         let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1629
+        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
1302 1630
         let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1303 1631
         let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1304 1632
         let sessions = relevantSessionObjects(
@@ -1307,6 +1635,7 @@ final class ChargeInsightsStore {
1307 1635
             sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1308 1636
             sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1309 1637
         )
1638
+        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
1310 1639
         let wiredMinimumCurrent = derivedMinimumCurrent(
1311 1640
             from: sessions,
1312 1641
             chargingTransportMode: .wired
@@ -1330,6 +1659,10 @@ final class ChargeInsightsStore {
1330 1659
             from: sessions,
1331 1660
             chargingProfile: wirelessProfile
1332 1661
         )
1662
+        let configuredCompletionCurrents = decodedCompletionCurrents(
1663
+            from: chargedDevice,
1664
+            key: "configuredCompletionCurrentsRawValue"
1665
+        )
1333 1666
         let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1334 1667
         let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1335 1668
         let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
@@ -1338,19 +1671,29 @@ final class ChargeInsightsStore {
1338 1671
         let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1339 1672
 
1340 1673
         let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
1674
+        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
1341 1675
         let preferredMinimumCurrent: Double?
1342 1676
         let preferredCapacity: Double?
1343 1677
         switch preferredChargingTransportMode {
1344 1678
         case .wired:
1345
-            preferredMinimumCurrent = configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
1679
+            preferredMinimumCurrent = configuredCompletionCurrents[
1680
+                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1681
+            ] ?? learnedCompletionCurrents[
1682
+                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1683
+            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
1346 1684
             preferredCapacity = wiredCapacity ?? wirelessCapacity
1347 1685
         case .wireless:
1348
-            preferredMinimumCurrent = configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
1686
+            preferredMinimumCurrent = configuredCompletionCurrents[
1687
+                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1688
+            ] ?? learnedCompletionCurrents[
1689
+                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1690
+            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
1349 1691
             preferredCapacity = wirelessCapacity ?? wiredCapacity
1350 1692
         }
1351 1693
 
1352 1694
         chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
1353 1695
         chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
1696
+        chargedDevice.setValue(encodedCompletionCurrents(learnedCompletionCurrents), forKey: "learnedCompletionCurrentsRawValue")
1354 1697
         chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
1355 1698
         chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
1356 1699
         chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
@@ -1484,9 +1827,12 @@ final class ChargeInsightsStore {
1484 1827
             startedAt: startedAt,
1485 1828
             endedAt: dateValue(object, key: "endedAt"),
1486 1829
             lastObservedAt: lastObservedAt,
1830
+            pausedAt: dateValue(object, key: "pausedAt"),
1487 1831
             status: status,
1488 1832
             sourceMode: sourceMode,
1489 1833
             chargingTransportMode: chargingTransportMode,
1834
+            chargingStateMode: chargingStateMode(for: object),
1835
+            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
1490 1836
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1491 1837
             effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1492 1838
             measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
@@ -1565,21 +1911,23 @@ final class ChargeInsightsStore {
1565 1911
         )
1566 1912
     }
1567 1913
 
1568
-    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1914
+    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1569 1915
         fetchSessionObject(
1570 1916
             predicate: NSPredicate(
1571
-                format: "meterMACAddress == %@ AND statusRawValue == %@",
1917
+                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
1572 1918
                 normalizedMACAddress(meterMACAddress),
1573
-                ChargeSessionStatus.active.rawValue
1919
+                ChargeSessionStatus.active.rawValue,
1920
+                ChargeSessionStatus.paused.rawValue
1574 1921
             )
1575 1922
         )
1576 1923
     }
1577 1924
 
1578
-    private func fetchLatestSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1925
+    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1579 1926
         fetchSessionObject(
1580 1927
             predicate: NSPredicate(
1581
-                format: "meterMACAddress == %@",
1582
-                normalizedMACAddress(meterMACAddress)
1928
+                format: "meterMACAddress == %@ AND statusRawValue == %@",
1929
+                normalizedMACAddress(meterMACAddress),
1930
+                ChargeSessionStatus.active.rawValue
1583 1931
             )
1584 1932
         )
1585 1933
     }
@@ -1700,20 +2048,46 @@ final class ChargeInsightsStore {
1700 2048
     private func resolvedStopThreshold(
1701 2049
         for chargedDevice: NSManagedObject,
1702 2050
         chargingTransportMode: ChargingTransportMode,
1703
-        fallback: Double
1704
-    ) -> Double {
1705
-        let persistedMinimum: Double?
2051
+        chargingStateMode: ChargingStateMode,
2052
+        charger: NSManagedObject?,
2053
+        fallback: Double?
2054
+    ) -> Double? {
2055
+        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2056
+            return nil
2057
+        }
2058
+
2059
+        let sessionKind = ChargeSessionKind(
2060
+            chargingTransportMode: chargingTransportMode,
2061
+            chargingStateMode: chargingStateMode
2062
+        )
2063
+        let configuredCurrents = decodedCompletionCurrents(
2064
+            from: chargedDevice,
2065
+            key: "configuredCompletionCurrentsRawValue"
2066
+        )
2067
+        let learnedCurrents = decodedCompletionCurrents(
2068
+            from: chargedDevice,
2069
+            key: "learnedCompletionCurrentsRawValue"
2070
+        )
2071
+        let legacyCurrent: Double?
1706 2072
         switch chargingTransportMode {
1707 2073
         case .wired:
1708
-            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2074
+            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1709 2075
                 ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
1710 2076
                 ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1711 2077
         case .wireless:
1712
-            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2078
+            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1713 2079
                 ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
1714 2080
                 ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1715 2081
         }
1716
-        return max(persistedMinimum ?? fallback, 0.01)
2082
+
2083
+        let resolvedCurrent = configuredCurrents[sessionKind]
2084
+            ?? learnedCurrents[sessionKind]
2085
+            ?? legacyCurrent
2086
+            ?? fallback
2087
+        guard let resolvedCurrent, resolvedCurrent > 0 else {
2088
+            return nil
2089
+        }
2090
+        return max(resolvedCurrent, 0.01)
1717 2091
     }
1718 2092
 
1719 2093
     private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
@@ -1741,6 +2115,40 @@ final class ChargeInsightsStore {
1741 2115
         return boolValue(chargedDevice, key: "supportsWirelessCharging")
1742 2116
     }
1743 2117
 
2118
+    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2119
+        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2120
+           let availability = ChargingStateAvailability(rawValue: rawValue) {
2121
+            return availability
2122
+        }
2123
+        return ChargingStateAvailability.fallback(
2124
+            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2125
+        )
2126
+    }
2127
+
2128
+    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2129
+        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2130
+           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2131
+            return chargingStateMode
2132
+        }
2133
+
2134
+        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2135
+           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2136
+            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2137
+        }
2138
+
2139
+        return .on
2140
+    }
2141
+
2142
+    private func resolvedChargingStateMode(
2143
+        _ chargingStateMode: ChargingStateMode,
2144
+        availability: ChargingStateAvailability
2145
+    ) -> ChargingStateMode {
2146
+        if availability.supportedModes.contains(chargingStateMode) {
2147
+            return chargingStateMode
2148
+        }
2149
+        return availability.supportedModes.first ?? .on
2150
+    }
2151
+
1744 2152
     private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
1745 2153
         guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
1746 2154
               let profile = WirelessChargingProfile(rawValue: rawValue) else {
@@ -1770,6 +2178,90 @@ final class ChargeInsightsStore {
1770 2178
         }
1771 2179
     }
1772 2180
 
2181
+    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2182
+        let payload = Dictionary(
2183
+            uniqueKeysWithValues: currents.map { key, value in
2184
+                (key.rawValue, value)
2185
+            }
2186
+        )
2187
+        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2188
+            return nil
2189
+        }
2190
+        return String(data: data, encoding: .utf8)
2191
+    }
2192
+
2193
+    private func decodedCompletionCurrents(
2194
+        from object: NSManagedObject,
2195
+        key: String
2196
+    ) -> [ChargeSessionKind: Double] {
2197
+        guard let rawValue = stringValue(object, key: key),
2198
+              let data = rawValue.data(using: .utf8),
2199
+              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2200
+            return [:]
2201
+        }
2202
+
2203
+        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2204
+            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2205
+                return
2206
+            }
2207
+            result[sessionKind] = entry.value
2208
+        }
2209
+    }
2210
+
2211
+    private func legacyConfiguredCompletionCurrent(
2212
+        for currents: [ChargeSessionKind: Double],
2213
+        chargingTransportMode: ChargingTransportMode
2214
+    ) -> Double? {
2215
+        let candidates = currents
2216
+            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2217
+            .sorted { lhs, rhs in
2218
+                lhs.key.rawValue < rhs.key.rawValue
2219
+            }
2220
+            .map(\.value)
2221
+        return candidates.first
2222
+    }
2223
+
2224
+    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2225
+        guard let charger else {
2226
+            return nil
2227
+        }
2228
+        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2229
+        guard let idleCurrent, idleCurrent >= 0 else {
2230
+            return nil
2231
+        }
2232
+        return idleCurrent
2233
+    }
2234
+
2235
+    private func effectiveCurrentAmps(
2236
+        fromMeasuredCurrent currentAmps: Double,
2237
+        chargingTransportMode: ChargingTransportMode,
2238
+        charger: NSManagedObject?
2239
+    ) -> Double {
2240
+        switch chargingTransportMode {
2241
+        case .wired:
2242
+            return max(currentAmps, 0)
2243
+        case .wireless:
2244
+            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2245
+                return max(currentAmps, 0)
2246
+            }
2247
+            return max(currentAmps - idleCurrent, 0)
2248
+        }
2249
+    }
2250
+
2251
+    private func hasObservedChargeFlow(
2252
+        currentAmps: Double,
2253
+        chargingTransportMode: ChargingTransportMode,
2254
+        charger: NSManagedObject?,
2255
+        stopThreshold: Double?
2256
+    ) -> Bool {
2257
+        let effectiveCurrent = effectiveCurrentAmps(
2258
+            fromMeasuredCurrent: currentAmps,
2259
+            chargingTransportMode: chargingTransportMode,
2260
+            charger: charger
2261
+        )
2262
+        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2263
+    }
2264
+
1773 2265
     private func derivedMinimumCurrent(
1774 2266
         from sessions: [NSManagedObject],
1775 2267
         chargingTransportMode: ChargingTransportMode
@@ -1779,6 +2271,9 @@ final class ChargeInsightsStore {
1779 2271
             guard self.chargingTransportMode(for: session) == chargingTransportMode else {
1780 2272
                 return nil
1781 2273
             }
2274
+            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2275
+                return nil
2276
+            }
1782 2277
             guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
1783 2278
                 return nil
1784 2279
             }
@@ -1790,6 +2285,37 @@ final class ChargeInsightsStore {
1790 2285
         return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
1791 2286
     }
1792 2287
 
2288
+    private func derivedCompletionCurrents(from sessions: [NSManagedObject]) -> [ChargeSessionKind: Double] {
2289
+        var groupedCurrents: [ChargeSessionKind: [Double]] = [:]
2290
+
2291
+        for session in sessions {
2292
+            guard statusValue(session, key: "statusRawValue") == .completed else {
2293
+                continue
2294
+            }
2295
+            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2296
+                continue
2297
+            }
2298
+            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2299
+                  completionCurrent > 0 else {
2300
+                continue
2301
+            }
2302
+
2303
+            let sessionKind = ChargeSessionKind(
2304
+                chargingTransportMode: chargingTransportMode(for: session),
2305
+                chargingStateMode: chargingStateMode(for: session)
2306
+            )
2307
+            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2308
+        }
2309
+
2310
+        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2311
+            let recentCurrents = Array(entry.value.suffix(5))
2312
+            guard !recentCurrents.isEmpty else {
2313
+                return
2314
+            }
2315
+            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2316
+        }
2317
+    }
2318
+
1793 2319
     private func derivedCapacity(
1794 2320
         from sessions: [NSManagedObject],
1795 2321
         chargingTransportMode: ChargingTransportMode,
@@ -1931,6 +2457,10 @@ final class ChargeInsightsStore {
1931 2457
             return .completed
1932 2458
         }
1933 2459
 
2460
+        if currentStatus != committedStatus {
2461
+            return .event
2462
+        }
2463
+
1934 2464
         if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
1935 2465
             || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
1936 2466
             return .event
+1 -2
USB Meter/Views/ChargedDevices/BatteryCheckpointEditorSheetView.swift
@@ -44,7 +44,6 @@ struct BatteryCheckpointEditorSheetView: View {
44 44
                             return
45 45
                         }
46 46
 
47
-                        _ = appData.ensureChargeSession(for: meter)
48 47
                         let didSave = appData.addBatteryCheckpoint(
49 48
                             percent: percent,
50 49
                             label: label,
@@ -57,7 +56,7 @@ struct BatteryCheckpointEditorSheetView: View {
57 56
                     .disabled(
58 57
                         (Double(batteryPercent) ?? -1) < 0
59 58
                             || (Double(batteryPercent) ?? 101) > 100
60
-                            || appData.currentChargedDeviceSummary(for: meter.btSerial.macAddress.description) == nil
59
+                            || appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) == nil
61 60
                     )
62 61
                 }
63 62
             }
+119 -22
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -14,6 +14,7 @@ struct ChargedDeviceDetailView: View {
14 14
     @State private var checkpointEditorVisibility = false
15 15
     @State private var targetNotificationEditorVisibility = false
16 16
     @State private var pendingSessionDeletion: ChargeSessionSummary?
17
+    @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
17 18
     @State private var deleteConfirmationVisibility = false
18 19
 
19 20
     let chargedDeviceID: UUID
@@ -99,6 +100,15 @@ struct ChargedDeviceDetailView: View {
99 100
                 .environmentObject(appData)
100 101
             }
101 102
         }
103
+        .sheet(item: $pendingSessionStopRequest) { request in
104
+            ChargeSessionCompletionSheetView(
105
+                sessionID: request.sessionID,
106
+                title: request.title,
107
+                confirmTitle: request.confirmTitle,
108
+                explanation: request.explanation
109
+            )
110
+            .environmentObject(appData)
111
+        }
102 112
         .alert(item: $pendingSessionDeletion) { session in
103 113
             Alert(
104 114
                 title: Text("Delete Session?"),
@@ -155,8 +165,8 @@ struct ChargedDeviceDetailView: View {
155 165
     private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
156 166
         MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
157 167
             MeterInfoRowView(
158
-                label: "Supports Charging While Off",
159
-                value: chargedDevice.supportsChargingWhileOff ? "Yes" : "No"
168
+                label: "Charge Modes",
169
+                value: chargedDevice.chargingStateAvailability.title
160 170
             )
161 171
             MeterInfoRowView(
162 172
                 label: "Charging Support",
@@ -172,16 +182,11 @@ struct ChargedDeviceDetailView: View {
172 182
                     value: chargedDevice.wirelessChargingProfile.title
173 183
                 )
174 184
             }
175
-            if chargedDevice.supportsWiredCharging {
176
-                MeterInfoRowView(
177
-                    label: "Wired Completion Current",
178
-                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wired)
179
-                )
180
-            }
181
-            if chargedDevice.supportsWirelessCharging {
185
+
186
+            ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
182 187
                 MeterInfoRowView(
183
-                    label: "Wireless Completion Current",
184
-                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wireless)
188
+                    label: "\(sessionKind.shortTitle) Stop Current",
189
+                    value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
185 190
                 )
186 191
             }
187 192
             MeterInfoRowView(
@@ -234,6 +239,11 @@ struct ChargedDeviceDetailView: View {
234 239
                         value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
235 240
                     )
236 241
                 }
242
+                if chargedDevice.chargerIdleCurrentAmps == nil {
243
+                    Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
244
+                        .font(.caption2)
245
+                        .foregroundColor(.orange)
246
+                }
237 247
             }
238 248
             MeterInfoRowView(
239 249
                 label: "End-of-Charge Current",
@@ -258,9 +268,11 @@ struct ChargedDeviceDetailView: View {
258 268
         _ activeSession: ChargeSessionSummary,
259 269
         chargedDevice: ChargedDeviceSummary
260 270
     ) -> some View {
261
-        MeterInfoCardView(title: "Active Session", tint: .green) {
271
+        MeterInfoCardView(title: "Open Session", tint: .green) {
262 272
             MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
273
+            MeterInfoRowView(label: "Status", value: activeSession.status.title)
263 274
             MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
275
+            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
264 276
             MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
265 277
             if activeSession.chargingTransportMode == .wireless,
266 278
                let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
@@ -268,7 +280,7 @@ struct ChargedDeviceDetailView: View {
268 280
                 MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
269 281
             }
270 282
             MeterInfoRowView(label: "Charge", value: "\(activeSession.measuredChargeAh.format(decimalDigits: 3)) Ah")
271
-            MeterInfoRowView(label: "Stop Threshold", value: "\(activeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
283
+            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
272 284
             MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
273 285
             if chargedDevice.isCharger == false,
274 286
                let chargerID = activeSession.chargerID,
@@ -294,6 +306,11 @@ struct ChargedDeviceDetailView: View {
294 306
                     value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
295 307
                 )
296 308
             }
309
+            if let sessionWarning = sessionWarning(for: activeSession) {
310
+                Text(sessionWarning)
311
+                    .font(.caption2)
312
+                    .foregroundColor(.orange)
313
+            }
297 314
             if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
298 315
                 Text(wirelessSessionHint)
299 316
                     .font(.caption2)
@@ -337,12 +354,39 @@ struct ChargedDeviceDetailView: View {
337 354
                 .buttonStyle(.plain)
338 355
             }
339 356
 
340
-            Button(activeSession.requiresCompletionConfirmation ? "Finish Session" : "End Session") {
341
-                _ = appData.confirmChargeSessionCompletion(sessionID: activeSession.id)
357
+            if activeSession.status == .active {
358
+                Button("Pause Session") {
359
+                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
360
+                }
361
+                .frame(maxWidth: .infinity)
362
+                .padding(.vertical, 10)
363
+                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
364
+                .buttonStyle(.plain)
365
+            } else if activeSession.status == .paused {
366
+                Button("Resume Session") {
367
+                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
368
+                }
369
+                .frame(maxWidth: .infinity)
370
+                .padding(.vertical, 10)
371
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
372
+                .buttonStyle(.plain)
373
+
374
+                Text("Paused sessions close automatically after 10 minutes.")
375
+                    .font(.caption2)
376
+                    .foregroundColor(.secondary)
377
+            }
378
+
379
+            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
380
+                pendingSessionStopRequest = DeviceSessionStopRequest(
381
+                    sessionID: activeSession.id,
382
+                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
383
+                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
384
+                    explanation: "Add the final battery checkpoint before closing this session."
385
+                )
342 386
             }
343 387
             .frame(maxWidth: .infinity)
344 388
             .padding(.vertical, 10)
345
-            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
389
+            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
346 390
             .buttonStyle(.plain)
347 391
 
348 392
             if activeSession.requiresCompletionConfirmation {
@@ -413,7 +457,7 @@ struct ChargedDeviceDetailView: View {
413 457
                 VStack(alignment: .leading, spacing: 4) {
414 458
                     Text("Stored Session Curve")
415 459
                         .font(.headline)
416
-                    Text(session.status == .active ? "Active session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
460
+                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
417 461
                         .font(.caption)
418 462
                         .foregroundColor(.secondary)
419 463
                 }
@@ -607,6 +651,7 @@ struct ChargedDeviceDetailView: View {
607 651
         }
608 652
 
609 653
         components.append(session.chargingTransportMode.title)
654
+        components.append(session.chargingStateMode.title)
610 655
         components.append(session.sourceMode.title)
611 656
         return components.joined(separator: " • ")
612 657
     }
@@ -642,10 +687,12 @@ struct ChargedDeviceDetailView: View {
642 687
         switch session.status {
643 688
         case .active:
644 689
             return .green
690
+        case .paused:
691
+            return .orange
645 692
         case .completed:
646 693
             return .teal
647 694
         case .abandoned:
648
-            return .orange
695
+            return .secondary
649 696
         }
650 697
     }
651 698
 
@@ -666,23 +713,62 @@ struct ChargedDeviceDetailView: View {
666 713
 
667 714
     private func completionCurrentDescription(
668 715
         for chargedDevice: ChargedDeviceSummary,
669
-        chargingTransportMode: ChargingTransportMode
716
+        sessionKind: ChargeSessionKind
670 717
     ) -> String {
671
-        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: chargingTransportMode) {
672
-            if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode),
718
+        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
719
+            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
673 720
                abs(configuredCurrent - learnedCurrent) >= 0.01 {
674 721
                 return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
675 722
             }
676 723
             return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
677 724
         }
678 725
 
679
-        if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode) {
726
+        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
680 727
             return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
681 728
         }
682 729
 
683 730
         return "Learning"
684 731
     }
685 732
 
733
+    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
734
+        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
735
+            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
736
+                ChargeSessionKind(
737
+                    chargingTransportMode: chargingTransportMode,
738
+                    chargingStateMode: chargingStateMode
739
+                )
740
+            }
741
+        }
742
+    }
743
+
744
+    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
745
+        if session.autoStopEnabled == false {
746
+            return "Manual"
747
+        }
748
+
749
+        if let sessionWarning = sessionWarning(for: session),
750
+           sessionWarning.contains("idle-current") {
751
+            return "Blocked by charger setup"
752
+        }
753
+
754
+        if session.stopThresholdAmps > 0 {
755
+            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
756
+        }
757
+
758
+        return "Learning"
759
+    }
760
+
761
+    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
762
+        guard session.chargingTransportMode == .wireless,
763
+              let chargerID = session.chargerID,
764
+              let charger = appData.chargedDeviceSummary(id: chargerID),
765
+              charger.chargerIdleCurrentAmps == nil else {
766
+            return nil
767
+        }
768
+
769
+        return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session."
770
+    }
771
+
686 772
     private var deletionTitle: String {
687 773
         appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
688 774
     }
@@ -910,3 +996,14 @@ private struct ChargedDeviceTargetNotificationEditorSheetView: View {
910 996
         .navigationViewStyle(StackNavigationViewStyle())
911 997
     }
912 998
 }
999
+
1000
+private struct DeviceSessionStopRequest: Identifiable {
1001
+    let sessionID: UUID
1002
+    let title: String
1003
+    let confirmTitle: String
1004
+    let explanation: String
1005
+
1006
+    var id: UUID {
1007
+        sessionID
1008
+    }
1009
+}
+110 -27
USB Meter/Views/ChargedDevices/ChargedDeviceEditorSheetView.swift
@@ -17,13 +17,12 @@ struct ChargedDeviceEditorSheetView: View {
17 17
 
18 18
     @State private var name: String
19 19
     @State private var deviceClass: ChargedDeviceClass
20
-    @State private var supportsChargingWhileOff: Bool
20
+    @State private var chargingStateAvailability: ChargingStateAvailability
21 21
     @State private var supportsWiredCharging: Bool
22 22
     @State private var supportsWirelessCharging: Bool
23 23
     @State private var preferredChargingTransportMode: ChargingTransportMode
24 24
     @State private var wirelessChargingProfile: WirelessChargingProfile
25
-    @State private var wiredChargeCompletionCurrentText: String
26
-    @State private var wirelessChargeCompletionCurrentText: String
25
+    @State private var completionCurrentTexts: [ChargeSessionKind: String]
27 26
     @State private var notes: String
28 27
 
29 28
     init(
@@ -34,16 +33,18 @@ struct ChargedDeviceEditorSheetView: View {
34 33
         self.meterMACAddress = meterMACAddress
35 34
         self.chargedDevice = chargedDevice
36 35
         self.suggestedDeviceClass = suggestedDeviceClass
36
+
37 37
         let initialDeviceClass = chargedDevice?.deviceClass ?? suggestedDeviceClass ?? .iphone
38 38
         _name = State(initialValue: chargedDevice?.name ?? "")
39 39
         _deviceClass = State(initialValue: initialDeviceClass)
40
-        _supportsChargingWhileOff = State(initialValue: chargedDevice?.supportsChargingWhileOff ?? false)
40
+        _chargingStateAvailability = State(initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass))
41 41
         _supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
42 42
         _supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
43 43
         _preferredChargingTransportMode = State(initialValue: chargedDevice?.preferredChargingTransportMode ?? .wired)
44 44
         _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
45
-        _wiredChargeCompletionCurrentText = State(initialValue: Self.optionalCurrentText(chargedDevice?.wiredChargeCompletionCurrentAmps))
46
-        _wirelessChargeCompletionCurrentText = State(initialValue: Self.optionalCurrentText(chargedDevice?.wirelessChargeCompletionCurrentAmps))
45
+        _completionCurrentTexts = State(
46
+            initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)
47
+        )
47 48
         _notes = State(initialValue: chargedDevice?.notes ?? "")
48 49
     }
49 50
 
@@ -69,8 +70,14 @@ struct ChargedDeviceEditorSheetView: View {
69 70
                 }
70 71
 
71 72
                 Section(header: Text("Charge Behaviour")) {
72
-                    Toggle("Can finish charging while device is off", isOn: $supportsChargingWhileOff)
73
-                    Text("This flag is used when we decide which charge sessions are reliable enough to estimate battery capacity.")
73
+                    Picker("Session Modes", selection: $chargingStateAvailability) {
74
+                        ForEach(ChargingStateAvailability.allCases) { availability in
75
+                            Text(availability.title)
76
+                                .tag(availability)
77
+                        }
78
+                    }
79
+
80
+                    Text(chargingStateAvailability.description)
74 81
                         .font(.footnote)
75 82
                         .foregroundColor(.secondary)
76 83
                 }
@@ -92,7 +99,7 @@ struct ChargedDeviceEditorSheetView: View {
92 99
                             .foregroundColor(.secondary)
93 100
                     }
94 101
 
95
-                    if supportsWiredCharging || supportsWirelessCharging {
102
+                    if !supportedChargingModes.isEmpty {
96 103
                         Picker("Default session type", selection: preferredChargingTransportBinding) {
97 104
                             ForEach(supportedChargingModes) { chargingTransportMode in
98 105
                                 Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
@@ -107,20 +114,25 @@ struct ChargedDeviceEditorSheetView: View {
107 114
                 }
108 115
 
109 116
                 Section(header: Text("Charge Completion")) {
110
-                    if supportsWiredCharging {
111
-                        TextField("Wired completion current (A)", text: $wiredChargeCompletionCurrentText)
112
-                            .keyboardType(.decimalPad)
113
-                        Text("Leave empty to keep learning this value from wired sessions.")
117
+                    if applicableSessionKinds.isEmpty {
118
+                        Text("Enable at least one charging method to configure stop currents.")
114 119
                             .font(.footnote)
115 120
                             .foregroundColor(.secondary)
116
-                    }
121
+                    } else {
122
+                        ForEach(applicableSessionKinds) { sessionKind in
123
+                            VStack(alignment: .leading, spacing: 6) {
124
+                                TextField(
125
+                                    "\(sessionKind.shortTitle) completion current (A)",
126
+                                    text: completionCurrentTextBinding(for: sessionKind)
127
+                                )
128
+                                .keyboardType(.decimalPad)
117 129
 
118
-                    if supportsWirelessCharging {
119
-                        TextField("Wireless completion current (A)", text: $wirelessChargeCompletionCurrentText)
120
-                            .keyboardType(.decimalPad)
121
-                        Text("Leave empty to keep learning this value from wireless sessions.")
122
-                            .font(.footnote)
123
-                            .foregroundColor(.secondary)
130
+                                Text("Leave empty to keep learning this threshold from sessions of the same type.")
131
+                                    .font(.caption)
132
+                                    .foregroundColor(.secondary)
133
+                            }
134
+                            .padding(.vertical, 2)
135
+                        }
124 136
                     }
125 137
                 }
126 138
 
@@ -138,32 +150,31 @@ struct ChargedDeviceEditorSheetView: View {
138 150
                 }
139 151
                 ToolbarItem(placement: .confirmationAction) {
140 152
                     Button(saveButtonTitle) {
153
+                        let configuredCompletionCurrents = parsedCompletionCurrents
141 154
                         let didSave: Bool
142 155
                         if let chargedDevice {
143 156
                             didSave = appData.updateChargedDevice(
144 157
                                 id: chargedDevice.id,
145 158
                                 name: name,
146 159
                                 deviceClass: deviceClass,
147
-                                supportsChargingWhileOff: supportsChargingWhileOff,
160
+                                chargingStateAvailability: chargingStateAvailability,
148 161
                                 supportsWiredCharging: supportsWiredCharging,
149 162
                                 supportsWirelessCharging: supportsWirelessCharging,
150 163
                                 preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
151 164
                                 wirelessChargingProfile: wirelessChargingProfile,
152
-                                wiredChargeCompletionCurrentAmps: parsedOptionalCurrent(wiredChargeCompletionCurrentText),
153
-                                wirelessChargeCompletionCurrentAmps: parsedOptionalCurrent(wirelessChargeCompletionCurrentText),
165
+                                configuredCompletionCurrents: configuredCompletionCurrents,
154 166
                                 notes: notes
155 167
                             )
156 168
                         } else {
157 169
                             didSave = appData.createChargedDevice(
158 170
                                 name: name,
159 171
                                 deviceClass: deviceClass,
160
-                                supportsChargingWhileOff: supportsChargingWhileOff,
172
+                                chargingStateAvailability: chargingStateAvailability,
161 173
                                 supportsWiredCharging: supportsWiredCharging,
162 174
                                 supportsWirelessCharging: supportsWirelessCharging,
163 175
                                 preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
164 176
                                 wirelessChargingProfile: wirelessChargingProfile,
165
-                                wiredChargeCompletionCurrentAmps: parsedOptionalCurrent(wiredChargeCompletionCurrentText),
166
-                                wirelessChargeCompletionCurrentAmps: parsedOptionalCurrent(wirelessChargeCompletionCurrentText),
177
+                                configuredCompletionCurrents: configuredCompletionCurrents,
167 178
                                 notes: notes,
168 179
                                 meterMACAddress: meterMACAddress
169 180
                             )
@@ -176,6 +187,7 @@ struct ChargedDeviceEditorSheetView: View {
176 187
                     .disabled(
177 188
                         name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
178 189
                             || (!supportsWiredCharging && !supportsWirelessCharging)
190
+                            || hasInvalidCompletionCurrentEntry
179 191
                     )
180 192
                 }
181 193
             }
@@ -214,6 +226,34 @@ struct ChargedDeviceEditorSheetView: View {
214 226
         return modes
215 227
     }
216 228
 
229
+    private var applicableSessionKinds: [ChargeSessionKind] {
230
+        supportedChargingModes.flatMap { chargingTransportMode in
231
+            chargingStateAvailability.supportedModes.map { chargingStateMode in
232
+                ChargeSessionKind(
233
+                    chargingTransportMode: chargingTransportMode,
234
+                    chargingStateMode: chargingStateMode
235
+                )
236
+            }
237
+        }
238
+    }
239
+
240
+    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
241
+        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
242
+            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
243
+                return
244
+            }
245
+            result[sessionKind] = value
246
+        }
247
+    }
248
+
249
+    private var hasInvalidCompletionCurrentEntry: Bool {
250
+        applicableSessionKinds.contains { sessionKind in
251
+            let text = completionCurrentTexts[sessionKind] ?? ""
252
+            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
253
+            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
254
+        }
255
+    }
256
+
217 257
     private var resolvedPreferredChargingTransportMode: ChargingTransportMode {
218 258
         if supportedChargingModes.contains(preferredChargingTransportMode) {
219 259
             return preferredChargingTransportMode
@@ -228,7 +268,20 @@ struct ChargedDeviceEditorSheetView: View {
228 268
         )
229 269
     }
230 270
 
271
+    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
272
+        Binding(
273
+            get: { completionCurrentTexts[sessionKind] ?? "" },
274
+            set: { completionCurrentTexts[sessionKind] = $0 }
275
+        )
276
+    }
277
+
231 278
     private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
279
+        if chargedDevice != nil {
280
+            return
281
+        }
282
+
283
+        chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
284
+
232 285
         switch deviceClass {
233 286
         case .iphone:
234 287
             supportsWiredCharging = true
@@ -260,7 +313,22 @@ struct ChargedDeviceEditorSheetView: View {
260 313
         guard !normalized.isEmpty else {
261 314
             return nil
262 315
         }
263
-        return Double(normalized)
316
+        guard let value = Double(normalized), value > 0 else {
317
+            return nil
318
+        }
319
+        return value
320
+    }
321
+
322
+    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
323
+        guard let chargedDevice else {
324
+            return [:]
325
+        }
326
+
327
+        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
328
+            result[sessionKind] = optionalCurrentText(
329
+                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
330
+            )
331
+        }
264 332
     }
265 333
 
266 334
     private static func optionalCurrentText(_ value: Double?) -> String {
@@ -269,4 +337,19 @@ struct ChargedDeviceEditorSheetView: View {
269 337
         }
270 338
         return value.format(decimalDigits: 2)
271 339
     }
340
+
341
+    private static func suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
342
+        switch deviceClass {
343
+        case .iphone:
344
+            return .onOrOff
345
+        case .watch:
346
+            return .onOnly
347
+        case .powerbank:
348
+            return .offOnly
349
+        case .charger:
350
+            return .onOnly
351
+        case .other:
352
+            return .onOnly
353
+        }
354
+    }
272 355
 }
+12 -510
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -9,524 +9,22 @@
9 9
 import SwiftUI
10 10
 
11 11
 struct ChargeRecordSheetView: View {
12
-    
13 12
     @Binding var visibility: Bool
14
-    @EnvironmentObject private var appData: AppData
15
-    @EnvironmentObject private var usbMeter: Meter
16
-    @State private var chargedDeviceLibraryVisibility = false
17
-    @State private var chargerLibraryVisibility = false
18
-    @State private var checkpointEditorVisibility = false
19
-    @State private var editingChargedDevice: ChargedDeviceSummary?
20
-    @State private var targetNotificationEditorVisibility = false
21
-    
13
+
22 14
     var body: some View {
23 15
         NavigationView {
24
-            ScrollView {
25
-                VStack(spacing: 16) {
26
-                    VStack(alignment: .leading, spacing: 8) {
27
-                        HStack {
28
-                            Text("Charge Record")
29
-                                .font(.system(.title3, design: .rounded).weight(.bold))
30
-                            Spacer()
31
-                            Text(usbMeter.chargeRecordStatusText)
32
-                                .font(.caption.weight(.bold))
33
-                                .foregroundColor(usbMeter.chargeRecordStatusColor)
34
-                                .padding(.horizontal, 10)
35
-                                .padding(.vertical, 6)
36
-                                .meterCard(
37
-                                    tint: usbMeter.chargeRecordStatusColor,
38
-                                    fillOpacity: 0.18,
39
-                                    strokeOpacity: 0.24,
40
-                                    cornerRadius: 999
41
-                                )
42
-                        }
43
-                        Text("App-side charge accumulation based on the stop-threshold workflow.")
44
-                            .font(.footnote)
45
-                            .foregroundColor(.secondary)
46
-                    }
47
-                    .frame(maxWidth: .infinity)
48
-                    .padding(18)
49
-                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
50
-
51
-                    chargedDeviceSection
52
-
53
-                    if let activeChargeSession {
54
-                        chargeMonitorSection(activeChargeSession)
55
-                    }
56
-
57
-                    ChargeRecordMetricsTableView(
58
-                        labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
59
-                        values: [
60
-                            "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
61
-                            "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
62
-                            usbMeter.chargeRecordDurationDescription,
63
-                            "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
64
-                        ]
65
-                    )
66
-                    .padding(18)
67
-                    .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
68
-
69
-                    if usbMeter.chargeRecordTimeRange != nil {
70
-                        VStack(alignment: .leading, spacing: 12) {
71
-                            HStack {
72
-                                Text("Charge Curve")
73
-                                    .font(.headline)
74
-                                Spacer()
75
-                                Button("Reset Graph") {
76
-                                    usbMeter.resetChargeRecordGraph()
77
-                                }
78
-                                .foregroundColor(.red)
79
-                            }
80
-                            MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
81
-                                .environmentObject(usbMeter.measurements)
82
-                                .frame(minHeight: 220)
83
-                            Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
84
-                                .font(.footnote)
85
-                                .foregroundColor(.secondary)
16
+            MeterChargeRecordContentView()
17
+                .navigationTitle("Charge Record")
18
+                .navigationBarTitleDisplayMode(.inline)
19
+                .toolbar {
20
+                    ToolbarItem(placement: .cancellationAction) {
21
+                        Button("Done") {
22
+                            visibility = false
86 23
                         }
87
-                        .padding(18)
88
-                        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
89
-                    }
90
-
91
-                    VStack(alignment: .leading, spacing: 12) {
92
-                        Text("Stop Threshold")
93
-                            .font(.headline)
94
-                        Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
95
-                        Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
96
-                            .font(.footnote)
97
-                            .foregroundColor(.secondary)
98
-                        Button("Reset") {
99
-                            usbMeter.resetChargeRecord()
100
-                        }
101
-                        .frame(maxWidth: .infinity)
102
-                        .padding(.vertical, 10)
103
-                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
104
-                        .buttonStyle(.plain)
105
-                    }
106
-                    .padding(18)
107
-                    .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
108
-
109
-                    if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
110
-                        VStack(alignment: .leading, spacing: 12) {
111
-                            Text("Meter Totals")
112
-                                .font(.headline)
113
-                            ChargeRecordMetricsTableView(
114
-                                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
115
-                                values: [
116
-                                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
117
-                                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
118
-                                    usbMeter.recordingDurationDescription,
119
-                                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
120
-                                ]
121
-                            )
122
-                            Text("These values are reported by the meter for the active data group.")
123
-                                .font(.footnote)
124
-                                .foregroundColor(.secondary)
125
-                            if usbMeter.supportsDataGroupCommands {
126
-                                Button("Reset Active Group") {
127
-                                    usbMeter.clear()
128
-                                }
129
-                                .frame(maxWidth: .infinity)
130
-                                .padding(.vertical, 10)
131
-                                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
132
-                                .buttonStyle(.plain)
133
-                            }
134
-                        }
135
-                        .padding(18)
136
-                        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
137 24
                     }
138 25
                 }
139
-                .padding()
140
-            }
141
-            .background(
142
-                LinearGradient(
143
-                    colors: [.pink.opacity(0.14), Color.clear],
144
-                    startPoint: .topLeading,
145
-                    endPoint: .bottomTrailing
146
-                )
147
-                .ignoresSafeArea()
148
-            )
149
-            .navigationBarTitle("Charge Record", displayMode: .inline)
150
-            .navigationBarItems(trailing: Button("Done") { visibility.toggle() })
151 26
         }
152 27
         .navigationViewStyle(StackNavigationViewStyle())
153
-        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
154
-            ChargedDeviceLibrarySheetView(
155
-                visibility: $chargedDeviceLibraryVisibility,
156
-                meterMACAddress: usbMeter.btSerial.macAddress.description,
157
-                meterTint: usbMeter.color,
158
-                mode: .device
159
-            )
160
-            .environmentObject(appData)
161
-        }
162
-        .sheet(isPresented: $chargerLibraryVisibility) {
163
-            ChargedDeviceLibrarySheetView(
164
-                visibility: $chargerLibraryVisibility,
165
-                meterMACAddress: usbMeter.btSerial.macAddress.description,
166
-                meterTint: usbMeter.color,
167
-                mode: .charger
168
-            )
169
-            .environmentObject(appData)
170
-        }
171
-        .sheet(isPresented: $checkpointEditorVisibility) {
172
-            BatteryCheckpointEditorSheetView()
173
-                .environmentObject(appData)
174
-                .environmentObject(usbMeter)
175
-        }
176
-        .sheet(item: $editingChargedDevice) { chargedDevice in
177
-            ChargedDeviceEditorSheetView(
178
-                meterMACAddress: nil,
179
-                chargedDevice: chargedDevice
180
-            )
181
-            .environmentObject(appData)
182
-        }
183
-        .sheet(isPresented: $targetNotificationEditorVisibility) {
184
-            if let activeChargeSession {
185
-                BatteryTargetNotificationEditorSheetView(
186
-                    sessionID: activeChargeSession.id,
187
-                    initialTargetPercent: activeChargeSession.targetBatteryPercent
188
-                )
189
-                .environmentObject(appData)
190
-            }
191
-        }
192
-    }
193
-
194
-    private var selectedChargedDevice: ChargedDeviceSummary? {
195
-        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
196
-    }
197
-
198
-    private var activeChargeSession: ChargeSessionSummary? {
199
-        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
200
-    }
201
-
202
-    private var selectedCharger: ChargedDeviceSummary? {
203
-        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
204
-    }
205
-
206
-    private var chargedDeviceSection: some View {
207
-        VStack(alignment: .leading, spacing: 12) {
208
-            HStack {
209
-                Text("Device")
210
-                    .font(.headline)
211
-                Spacer()
212
-                Button("Library") {
213
-                    chargedDeviceLibraryVisibility = true
214
-                }
215
-            }
216
-
217
-            if let selectedChargedDevice {
218
-                HStack(alignment: .top, spacing: 14) {
219
-                    ChargedDeviceQRCodeView(
220
-                        qrIdentifier: selectedChargedDevice.qrIdentifier,
221
-                        side: 88
222
-                    )
223
-
224
-                    VStack(alignment: .leading, spacing: 8) {
225
-                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
226
-                            .font(.headline)
227
-
228
-                        Text(selectedChargedDevice.deviceClass.title)
229
-                            .font(.caption.weight(.semibold))
230
-                            .foregroundColor(.secondary)
231
-
232
-                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
233
-                            .font(.caption2)
234
-                            .foregroundColor(.secondary)
235
-
236
-                        if selectedChargedDevice.supportedChargingModes.count == 1 {
237
-                            Label(
238
-                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
239
-                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
240
-                            )
241
-                            .font(.caption2)
242
-                            .foregroundColor(.secondary)
243
-                        } else {
244
-                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
245
-                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
246
-                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
247
-                                        .tag(chargingTransportMode)
248
-                                }
249
-                            }
250
-                            .pickerStyle(.segmented)
251
-                        }
252
-
253
-                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
254
-                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
255
-                                .font(.caption)
256
-                                .foregroundColor(.secondary)
257
-                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
258
-                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
259
-                                .font(.caption)
260
-                                .foregroundColor(.secondary)
261
-                        }
262
-
263
-                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
264
-                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
265
-                                .font(.caption2)
266
-                                .foregroundColor(.secondary)
267
-                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
268
-                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
269
-                                .font(.caption2)
270
-                                .foregroundColor(.secondary)
271
-                        }
272
-                    }
273
-
274
-                    Spacer(minLength: 0)
275
-                }
276
-
277
-                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
278
-                    Divider()
279
-
280
-                    VStack(alignment: .leading, spacing: 10) {
281
-                        HStack {
282
-                            Text("Wireless Charger")
283
-                                .font(.subheadline.weight(.semibold))
284
-                            Spacer()
285
-                            Button(selectedCharger == nil ? "Select" : "Change") {
286
-                                chargerLibraryVisibility = true
287
-                            }
288
-                        }
289
-
290
-                        if let selectedCharger {
291
-                            HStack(alignment: .top, spacing: 12) {
292
-                                ChargedDeviceQRCodeView(
293
-                                    qrIdentifier: selectedCharger.qrIdentifier,
294
-                                    side: 62
295
-                                )
296
-
297
-                                VStack(alignment: .leading, spacing: 6) {
298
-                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
299
-                                        .font(.subheadline.weight(.semibold))
300
-
301
-                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
302
-                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
303
-                                            .font(.caption)
304
-                                            .foregroundColor(.secondary)
305
-                                    }
306
-
307
-                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
308
-                                        Text(
309
-                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
310
-                                                .map { "\($0.format(decimalDigits: 1)) V" }
311
-                                                .joined(separator: ", ")
312
-                                        )
313
-                                        .font(.caption2)
314
-                                        .foregroundColor(.secondary)
315
-                                    }
316
-                                }
317
-                            }
318
-                        } else {
319
-                            Text("Wireless sessions need a selected charger in addition to the charged device.")
320
-                                .font(.caption)
321
-                                .foregroundColor(.secondary)
322
-                        }
323
-                    }
324
-                }
325
-
326
-                Button("Add Battery Checkpoint") {
327
-                    checkpointEditorVisibility = true
328
-                }
329
-                .frame(maxWidth: .infinity)
330
-                .padding(.vertical, 10)
331
-                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
332
-                .buttonStyle(.plain)
333
-
334
-                Button("Edit Device") {
335
-                    editingChargedDevice = selectedChargedDevice
336
-                }
337
-                .frame(maxWidth: .infinity)
338
-                .padding(.vertical, 10)
339
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
340
-                .buttonStyle(.plain)
341
-            } else {
342
-                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.")
343
-                    .font(.footnote)
344
-                    .foregroundColor(.secondary)
345
-            }
346
-        }
347
-        .padding(18)
348
-        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
349
-    }
350
-
351
-    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
352
-        VStack(alignment: .leading, spacing: 12) {
353
-            Text("Charging Monitor")
354
-                .font(.headline)
355
-
356
-            ChargeRecordMetricsTableView(
357
-                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
358
-                values: [
359
-                    activeChargeSession.sourceMode.title,
360
-                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
361
-                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
362
-                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
363
-                ]
364
-            )
365
-
366
-            if let selectedChargedDevice,
367
-               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
368
-                VStack(alignment: .leading, spacing: 4) {
369
-                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
370
-                        .font(.caption.weight(.semibold))
371
-                    Text(
372
-                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
373
-                    )
374
-                    .font(.caption2)
375
-                    .foregroundColor(.secondary)
376
-                }
377
-            }
378
-
379
-            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
380
-                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
381
-                    .font(.caption.weight(.semibold))
382
-            } else {
383
-                Text("No target battery notification configured.")
384
-                    .font(.caption)
385
-                    .foregroundColor(.secondary)
386
-            }
387
-
388
-            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
389
-                targetNotificationEditorVisibility = true
390
-            }
391
-            .frame(maxWidth: .infinity)
392
-            .padding(.vertical, 10)
393
-            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
394
-            .buttonStyle(.plain)
395
-
396
-            if activeChargeSession.targetBatteryPercent != nil {
397
-                Button("Clear Target Notification") {
398
-                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
399
-                }
400
-                .frame(maxWidth: .infinity)
401
-                .padding(.vertical, 10)
402
-                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
403
-                .buttonStyle(.plain)
404
-            }
405
-
406
-            if activeChargeSession.requiresCompletionConfirmation {
407
-                completionConfirmationCard(activeChargeSession)
408
-            }
409
-
410
-            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
411
-                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
412
-                    .font(.caption.weight(.semibold))
413
-            }
414
-
415
-            Label(
416
-                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
417
-                systemImage: activeChargeSession.chargingTransportMode.symbolName
418
-            )
419
-            .font(.caption)
420
-            .foregroundColor(.secondary)
421
-
422
-            if activeChargeSession.chargingTransportMode == .wireless {
423
-                if let chargerID = activeChargeSession.chargerID,
424
-                   let charger = appData.chargedDeviceSummary(id: chargerID) {
425
-                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
426
-                        .font(.caption)
427
-                        .foregroundColor(.secondary)
428
-                } else {
429
-                    Text("No wireless charger is currently selected for this session.")
430
-                        .font(.caption)
431
-                        .foregroundColor(.orange)
432
-                }
433
-            }
434
-
435
-            if activeChargeSession.checkpoints.isEmpty == false {
436
-                VStack(alignment: .leading, spacing: 8) {
437
-                    Text("Battery Checkpoints")
438
-                        .font(.subheadline.weight(.semibold))
439
-
440
-                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
441
-                        HStack {
442
-                            Text(checkpoint.timestamp.format())
443
-                                .font(.caption2)
444
-                                .foregroundColor(.secondary)
445
-                            Spacer()
446
-                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
447
-                                .font(.caption.weight(.semibold))
448
-                            Text("•")
449
-                                .foregroundColor(.secondary)
450
-                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
451
-                                .font(.caption2)
452
-                                .foregroundColor(.secondary)
453
-                        }
454
-                    }
455
-                }
456
-            }
457
-
458
-            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.")
459
-                .font(.footnote)
460
-                .foregroundColor(.secondary)
461
-        }
462
-        .padding(18)
463
-        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
464
-    }
465
-
466
-    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
467
-        VStack(alignment: .leading, spacing: 10) {
468
-            Text("Completion Needs Confirmation")
469
-                .font(.subheadline.weight(.semibold))
470
-
471
-            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
472
-                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
473
-                    .font(.caption)
474
-                    .foregroundColor(.secondary)
475
-            } else {
476
-                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
477
-                    .font(.caption)
478
-                    .foregroundColor(.secondary)
479
-            }
480
-
481
-            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
482
-                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
483
-                    .font(.caption2)
484
-                    .foregroundColor(.secondary)
485
-            }
486
-
487
-            Button("Finish Session") {
488
-                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
489
-            }
490
-            .frame(maxWidth: .infinity)
491
-            .padding(.vertical, 10)
492
-            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
493
-            .buttonStyle(.plain)
494
-
495
-            Button("Keep Monitoring") {
496
-                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
497
-            }
498
-            .frame(maxWidth: .infinity)
499
-            .padding(.vertical, 10)
500
-            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
501
-            .buttonStyle(.plain)
502
-        }
503
-        .padding(14)
504
-        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
505
-    }
506
-
507
-    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
508
-        Binding(
509
-            get: {
510
-                effectiveChargingTransportMode(for: chargedDevice)
511
-            },
512
-            set: { newValue in
513
-                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
514
-            }
515
-        )
516
-    }
517
-
518
-    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
519
-        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
520
-    }
521
-
522
-    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
523
-        effectiveChargingTransportMode(for: chargedDevice) == .wireless
524
-    }
525
-}
526
-
527
-struct ChargeRecordSheetView_Previews: PreviewProvider {
528
-    static var previews: some View {
529
-        ChargeRecordSheetView(visibility: .constant(true))
530 28
     }
531 29
 }
532 30
 
@@ -579,3 +77,7 @@ struct BatteryTargetNotificationEditorSheetView: View {
579 77
         }
580 78
     }
581 79
 }
80
+
81
+#Preview {
82
+    ChargeRecordSheetView(visibility: .constant(true))
83
+}
+722 -286
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -6,128 +6,42 @@
6 6
 import SwiftUI
7 7
 
8 8
 struct MeterChargeRecordTabView: View {
9
+    var body: some View {
10
+        MeterChargeRecordContentView()
11
+    }
12
+}
13
+
14
+struct MeterChargeRecordContentView: View {
9 15
     @EnvironmentObject private var appData: AppData
10 16
     @EnvironmentObject private var usbMeter: Meter
17
+
11 18
     @State private var chargedDeviceLibraryVisibility = false
12 19
     @State private var chargerLibraryVisibility = false
13 20
     @State private var checkpointEditorVisibility = false
14 21
     @State private var editingChargedDevice: ChargedDeviceSummary?
15 22
     @State private var targetNotificationEditorVisibility = false
23
+    @State private var pendingStopRequest: ChargeSessionStopRequest?
24
+    @State private var draftChargingTransportMode: ChargingTransportMode?
25
+    @State private var draftChargingStateMode: ChargingStateMode?
26
+    @State private var draftAutoStopEnabled = true
27
+    @State private var initialCheckpoint = ""
16 28
 
17 29
     var body: some View {
18 30
         ScrollView {
19 31
             VStack(spacing: 16) {
20
-                VStack(alignment: .leading, spacing: 8) {
21
-                    HStack {
22
-                        Text("Charge Record")
23
-                            .font(.system(.title3, design: .rounded).weight(.bold))
24
-                        Spacer()
25
-                        Text(usbMeter.chargeRecordStatusText)
26
-                            .font(.caption.weight(.bold))
27
-                            .foregroundColor(usbMeter.chargeRecordStatusColor)
28
-                            .padding(.horizontal, 10)
29
-                            .padding(.vertical, 6)
30
-                            .meterCard(
31
-                                tint: usbMeter.chargeRecordStatusColor,
32
-                                fillOpacity: 0.18,
33
-                                strokeOpacity: 0.24,
34
-                                cornerRadius: 999
35
-                            )
36
-                    }
37
-                    Text("App-side charge accumulation based on the stop-threshold workflow.")
38
-                        .font(.footnote)
39
-                        .foregroundColor(.secondary)
40
-                }
41
-                .frame(maxWidth: .infinity)
42
-                .padding(18)
43
-                .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
44
-
45
-                chargedDeviceSection
32
+                headerCard
33
+                sessionSetupCard
46 34
 
47
-                if let activeChargeSession {
48
-                    chargeMonitorSection(activeChargeSession)
49
-                }
50
-
51
-                ChargeRecordMetricsTableView(
52
-                    labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
53
-                    values: [
54
-                        "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
55
-                        "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
56
-                        usbMeter.chargeRecordDurationDescription,
57
-                        "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
58
-                    ]
59
-                )
60
-                .padding(18)
61
-                .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
62
-
63
-                if usbMeter.chargeRecordTimeRange != nil {
64
-                    VStack(alignment: .leading, spacing: 12) {
65
-                        HStack {
66
-                            Text("Charge Curve")
67
-                                .font(.headline)
68
-                            Spacer()
69
-                            Button("Reset Graph") {
70
-                                usbMeter.resetChargeRecordGraph()
71
-                            }
72
-                            .foregroundColor(.red)
73
-                        }
74
-                        MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
75
-                            .environmentObject(usbMeter.measurements)
76
-                            .frame(minHeight: 220)
77
-                        Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
78
-                            .font(.footnote)
79
-                            .foregroundColor(.secondary)
80
-                    }
81
-                    .padding(18)
82
-                    .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
83
-                }
35
+                if let openChargeSession {
36
+                    chargingMonitorCard(openChargeSession)
84 37
 
85
-                VStack(alignment: .leading, spacing: 12) {
86
-                    Text("Stop Threshold")
87
-                        .font(.headline)
88
-                    Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
89
-                    Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
90
-                        .font(.footnote)
91
-                        .foregroundColor(.secondary)
92
-                    Button("Reset") {
93
-                        usbMeter.resetChargeRecord()
38
+                    if let sessionChartTimeRange {
39
+                        sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession)
94 40
                     }
95
-                    .frame(maxWidth: .infinity)
96
-                    .padding(.vertical, 10)
97
-                    .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
98
-                    .buttonStyle(.plain)
99 41
                 }
100
-                .padding(18)
101
-                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
102 42
 
103 43
                 if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
104
-                    VStack(alignment: .leading, spacing: 12) {
105
-                        Text("Meter Totals")
106
-                            .font(.headline)
107
-                        ChargeRecordMetricsTableView(
108
-                            labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
109
-                            values: [
110
-                                "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
111
-                                "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
112
-                                usbMeter.recordingDurationDescription,
113
-                                usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
114
-                            ]
115
-                        )
116
-                        Text("These values are reported by the meter for the active data group.")
117
-                            .font(.footnote)
118
-                            .foregroundColor(.secondary)
119
-                        if usbMeter.supportsDataGroupCommands {
120
-                            Button("Reset Active Group") {
121
-                                usbMeter.clear()
122
-                            }
123
-                            .frame(maxWidth: .infinity)
124
-                            .padding(.vertical, 10)
125
-                            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
126
-                            .buttonStyle(.plain)
127
-                        }
128
-                    }
129
-                    .padding(18)
130
-                    .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
44
+                    meterTotalsCard
131 45
                 }
132 46
             }
133 47
             .padding()
@@ -143,7 +57,7 @@ struct MeterChargeRecordTabView: View {
143 57
         .sheet(isPresented: $chargedDeviceLibraryVisibility) {
144 58
             ChargedDeviceLibrarySheetView(
145 59
                 visibility: $chargedDeviceLibraryVisibility,
146
-                meterMACAddress: usbMeter.btSerial.macAddress.description,
60
+                meterMACAddress: meterMACAddress,
147 61
                 meterTint: usbMeter.color,
148 62
                 mode: .device
149 63
             )
@@ -152,7 +66,7 @@ struct MeterChargeRecordTabView: View {
152 66
         .sheet(isPresented: $chargerLibraryVisibility) {
153 67
             ChargedDeviceLibrarySheetView(
154 68
                 visibility: $chargerLibraryVisibility,
155
-                meterMACAddress: usbMeter.btSerial.macAddress.description,
69
+                meterMACAddress: meterMACAddress,
156 70
                 meterTint: usbMeter.color,
157 71
                 mode: .charger
158 72
             )
@@ -171,190 +85,461 @@ struct MeterChargeRecordTabView: View {
171 85
             .environmentObject(appData)
172 86
         }
173 87
         .sheet(isPresented: $targetNotificationEditorVisibility) {
174
-            if let activeChargeSession {
88
+            if let openChargeSession {
175 89
                 BatteryTargetNotificationEditorSheetView(
176
-                    sessionID: activeChargeSession.id,
177
-                    initialTargetPercent: activeChargeSession.targetBatteryPercent
90
+                    sessionID: openChargeSession.id,
91
+                    initialTargetPercent: openChargeSession.targetBatteryPercent
178 92
                 )
179 93
                 .environmentObject(appData)
180 94
             }
181 95
         }
96
+        .sheet(item: $pendingStopRequest) { request in
97
+            ChargeSessionCompletionSheetView(
98
+                sessionID: request.sessionID,
99
+                title: request.title,
100
+                confirmTitle: request.confirmTitle,
101
+                explanation: request.explanation
102
+            )
103
+            .environmentObject(appData)
104
+        }
105
+        .onAppear {
106
+            syncDraftSelections()
107
+        }
108
+        .onChange(of: selectedChargedDevice?.id) { _ in
109
+            syncDraftSelections()
110
+        }
111
+        .onChange(of: openChargeSession?.id) { _ in
112
+            syncDraftSelections()
113
+        }
182 114
     }
183 115
 
184
-    private var selectedChargedDevice: ChargedDeviceSummary? {
185
-        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
116
+    private var meterMACAddress: String {
117
+        usbMeter.btSerial.macAddress.description
186 118
     }
187 119
 
188
-    private var activeChargeSession: ChargeSessionSummary? {
189
-        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
120
+    private var selectedChargedDevice: ChargedDeviceSummary? {
121
+        appData.currentChargedDeviceSummary(for: meterMACAddress)
190 122
     }
191 123
 
192 124
     private var selectedCharger: ChargedDeviceSummary? {
193
-        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
125
+        appData.currentChargerSummary(for: meterMACAddress)
194 126
     }
195 127
 
196
-    private var chargedDeviceSection: some View {
197
-        VStack(alignment: .leading, spacing: 12) {
128
+    private var openChargeSession: ChargeSessionSummary? {
129
+        appData.activeChargeSessionSummary(for: meterMACAddress)
130
+    }
131
+
132
+    private var selectedDraftTransportMode: ChargingTransportMode? {
133
+        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
134
+    }
135
+
136
+    private var selectedDraftChargingStateMode: ChargingStateMode? {
137
+        openChargeSession?.chargingStateMode ?? draftChargingStateMode
138
+    }
139
+
140
+    private var selectedDraftSessionKind: ChargeSessionKind? {
141
+        guard let chargingTransportMode = selectedDraftTransportMode,
142
+              let chargingStateMode = selectedDraftChargingStateMode else {
143
+            return nil
144
+        }
145
+
146
+        return ChargeSessionKind(
147
+            chargingTransportMode: chargingTransportMode,
148
+            chargingStateMode: chargingStateMode
149
+        )
150
+    }
151
+
152
+    private var selectedDraftStopThreshold: Double? {
153
+        guard let selectedChargedDevice,
154
+              let chargingTransportMode = selectedDraftTransportMode else {
155
+            return nil
156
+        }
157
+
158
+        return selectedChargedDevice.resolvedCompletionCurrentAmps(
159
+            for: chargingTransportMode,
160
+            chargingStateMode: selectedDraftChargingStateMode
161
+        )
162
+    }
163
+
164
+    private var initialCheckpointValue: Double? {
165
+        let normalized = initialCheckpoint
166
+            .trimmingCharacters(in: .whitespacesAndNewlines)
167
+            .replacingOccurrences(of: ",", with: ".")
168
+        guard let value = Double(normalized), value >= 0, value <= 100 else {
169
+            return nil
170
+        }
171
+        return value
172
+    }
173
+
174
+    private var requiresExplicitTransportSelection: Bool {
175
+        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
176
+    }
177
+
178
+    private var requiresExplicitChargingStateSelection: Bool {
179
+        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
180
+    }
181
+
182
+    private var canStartSession: Bool {
183
+        guard openChargeSession == nil,
184
+              let selectedChargedDevice,
185
+              let chargingTransportMode = selectedDraftTransportMode,
186
+              let chargingStateMode = selectedDraftChargingStateMode,
187
+              let initialCheckpointValue else {
188
+            return false
189
+        }
190
+
191
+        guard selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) else {
192
+            return false
193
+        }
194
+
195
+        guard selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) else {
196
+            return false
197
+        }
198
+
199
+        if chargingTransportMode == .wireless {
200
+            return selectedCharger != nil
201
+        }
202
+
203
+        return true
204
+    }
205
+
206
+    private var headerStatusTitle: String {
207
+        guard let openChargeSession else {
208
+            return "Idle"
209
+        }
210
+        return openChargeSession.status.title
211
+    }
212
+
213
+    private var headerStatusColor: Color {
214
+        guard let openChargeSession else {
215
+            return .secondary
216
+        }
217
+
218
+        switch openChargeSession.status {
219
+        case .active:
220
+            return .red
221
+        case .paused:
222
+            return .orange
223
+        case .completed:
224
+            return .green
225
+        case .abandoned:
226
+            return .secondary
227
+        }
228
+    }
229
+
230
+    private var sessionChartTimeRange: ClosedRange<Date>? {
231
+        guard let openChargeSession else {
232
+            return nil
233
+        }
234
+
235
+        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
236
+        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
237
+    }
238
+
239
+    private var draftAutoStopDescription: String {
240
+        guard let chargingTransportMode = selectedDraftTransportMode else {
241
+            return "Choose the charging type before starting the session."
242
+        }
243
+
244
+        if chargingTransportMode == .wireless, selectedCharger == nil {
245
+            return "Wireless sessions need a selected charger before they can start."
246
+        }
247
+
248
+        if draftAutoStopEnabled == false {
249
+            return "The session starts open-ended and will stop only when you pause or stop it manually."
250
+        }
251
+
252
+        if let setupWarning = setupWirelessThresholdWarning {
253
+            return setupWarning
254
+        }
255
+
256
+        if let selectedDraftSessionKind, let selectedDraftStopThreshold {
257
+            return "Auto-stop is ready for \(selectedDraftSessionKind.shortTitle.lowercased()) sessions at about \(selectedDraftStopThreshold.format(decimalDigits: 2)) A."
258
+        }
259
+
260
+        return "No stop threshold is known for this charging type yet, so the session starts open-ended."
261
+    }
262
+
263
+    private var setupWirelessThresholdWarning: String? {
264
+        guard selectedDraftTransportMode == .wireless else {
265
+            return nil
266
+        }
267
+
268
+        guard let selectedCharger else {
269
+            return nil
270
+        }
271
+
272
+        guard selectedCharger.chargerIdleCurrentAmps == nil else {
273
+            return nil
274
+        }
275
+
276
+        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."
277
+    }
278
+
279
+    private var headerCard: some View {
280
+        VStack(alignment: .leading, spacing: 8) {
281
+            HStack {
282
+                Text("Charging Session")
283
+                    .font(.system(.title3, design: .rounded).weight(.bold))
284
+                Spacer()
285
+                Text(headerStatusTitle)
286
+                    .font(.caption.weight(.bold))
287
+                    .foregroundColor(headerStatusColor)
288
+                    .padding(.horizontal, 10)
289
+                    .padding(.vertical, 6)
290
+                    .meterCard(
291
+                        tint: headerStatusColor,
292
+                        fillOpacity: 0.18,
293
+                        strokeOpacity: 0.24,
294
+                        cornerRadius: 999
295
+                    )
296
+            }
297
+
298
+            Text("Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit.")
299
+                .font(.footnote)
300
+                .foregroundColor(.secondary)
301
+        }
302
+        .frame(maxWidth: .infinity)
303
+        .padding(18)
304
+        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
305
+    }
306
+
307
+    private var sessionSetupCard: some View {
308
+        VStack(alignment: .leading, spacing: 14) {
198 309
             HStack {
199
-                Text("Device")
310
+                Text(openChargeSession == nil ? "Session Setup" : "Session Context")
200 311
                     .font(.headline)
201 312
                 Spacer()
202 313
                 Button("Library") {
203 314
                     chargedDeviceLibraryVisibility = true
204 315
                 }
316
+                .disabled(openChargeSession != nil)
205 317
             }
206 318
 
207 319
             if let selectedChargedDevice {
208
-                HStack(alignment: .top, spacing: 14) {
209
-                    ChargedDeviceQRCodeView(
210
-                        qrIdentifier: selectedChargedDevice.qrIdentifier,
211
-                        side: 88
212
-                    )
320
+                deviceSummary(selectedChargedDevice)
213 321
 
214
-                    VStack(alignment: .leading, spacing: 8) {
215
-                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
216
-                            .font(.headline)
322
+                if openChargeSession == nil {
323
+                    setupControls(for: selectedChargedDevice)
324
+                }
217 325
 
218
-                        Text(selectedChargedDevice.deviceClass.title)
219
-                            .font(.caption.weight(.semibold))
220
-                            .foregroundColor(.secondary)
326
+                Button("Edit Device") {
327
+                    editingChargedDevice = selectedChargedDevice
328
+                }
329
+                .frame(maxWidth: .infinity)
330
+                .padding(.vertical, 10)
331
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
332
+                .buttonStyle(.plain)
333
+            } else {
334
+                Text("Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection.")
335
+                    .font(.footnote)
336
+                    .foregroundColor(.secondary)
337
+            }
338
+        }
339
+        .padding(18)
340
+        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
341
+    }
342
+
343
+    private func deviceSummary(_ chargedDevice: ChargedDeviceSummary) -> some View {
344
+        VStack(alignment: .leading, spacing: 12) {
345
+            HStack(alignment: .top, spacing: 14) {
346
+                ChargedDeviceQRCodeView(
347
+                    qrIdentifier: chargedDevice.qrIdentifier,
348
+                    side: 88
349
+                )
350
+
351
+                VStack(alignment: .leading, spacing: 8) {
352
+                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
353
+                        .font(.headline)
354
+
355
+                    Text(chargedDevice.deviceClass.title)
356
+                        .font(.caption.weight(.semibold))
357
+                        .foregroundColor(.secondary)
358
+
359
+                    Text(chargedDevice.chargingStateAvailability.description)
360
+                        .font(.caption2)
361
+                        .foregroundColor(.secondary)
362
+
363
+                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
364
+                        .font(.caption2)
365
+                        .foregroundColor(.secondary)
221 366
 
222
-                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
367
+                    if let selectedDraftSessionKind,
368
+                       let threshold = chargedDevice.resolvedCompletionCurrentAmps(
369
+                        for: selectedDraftSessionKind.chargingTransportMode,
370
+                        chargingStateMode: selectedDraftSessionKind.chargingStateMode
371
+                       ) {
372
+                        Text("\(selectedDraftSessionKind.shortTitle) stop current: \(threshold.format(decimalDigits: 2)) A")
223 373
                             .font(.caption2)
224 374
                             .foregroundColor(.secondary)
225
-
226
-                        if selectedChargedDevice.supportedChargingModes.count == 1 {
227
-                            Label(
228
-                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
229
-                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
230
-                            )
375
+                    } else if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh(
376
+                        for: chargedDevice.preferredChargingTransportMode
377
+                    ) {
378
+                        Text("Estimated \(chargedDevice.preferredChargingTransportMode.title.lowercased()) capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
231 379
                             .font(.caption2)
232 380
                             .foregroundColor(.secondary)
233
-                        } else {
234
-                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
235
-                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
236
-                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
237
-                                        .tag(chargingTransportMode)
238
-                                }
239
-                            }
240
-                            .pickerStyle(.segmented)
241
-                        }
381
+                    }
382
+                }
242 383
 
243
-                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
244
-                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
245
-                                .font(.caption)
246
-                                .foregroundColor(.secondary)
247
-                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
248
-                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
249
-                                .font(.caption)
250
-                                .foregroundColor(.secondary)
251
-                        }
384
+                Spacer(minLength: 0)
385
+            }
252 386
 
253
-                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
254
-                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
255
-                                .font(.caption2)
256
-                                .foregroundColor(.secondary)
257
-                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
258
-                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
259
-                                .font(.caption2)
260
-                                .foregroundColor(.secondary)
387
+            if showsWirelessChargerSection {
388
+                Divider()
389
+                wirelessChargerSection
390
+            }
391
+        }
392
+    }
393
+
394
+    private func setupControls(for chargedDevice: ChargedDeviceSummary) -> some View {
395
+        VStack(alignment: .leading, spacing: 12) {
396
+            if requiresExplicitTransportSelection {
397
+                VStack(alignment: .leading, spacing: 8) {
398
+                    Text("Charging Type")
399
+                        .font(.subheadline.weight(.semibold))
400
+
401
+                    Picker("Charging Type", selection: $draftChargingTransportMode) {
402
+                        ForEach(chargedDevice.supportedChargingModes) { chargingTransportMode in
403
+                            Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
404
+                                .tag(Optional(chargingTransportMode))
261 405
                         }
262 406
                     }
407
+                    .pickerStyle(.segmented)
263 408
 
264
-                    Spacer(minLength: 0)
409
+                    if draftChargingTransportMode == nil {
410
+                        Text("Pick the charging type explicitly before starting.")
411
+                            .font(.caption2)
412
+                            .foregroundColor(.orange)
413
+                    }
265 414
                 }
415
+            } else if let chargingTransportMode = chargedDevice.supportedChargingModes.first {
416
+                Label(
417
+                    "Charging type: \(chargingTransportMode.title)",
418
+                    systemImage: chargingTransportMode.symbolName
419
+                )
420
+                .font(.subheadline.weight(.semibold))
421
+            }
266 422
 
267
-                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
268
-                    Divider()
423
+            if requiresExplicitChargingStateSelection {
424
+                VStack(alignment: .leading, spacing: 8) {
425
+                    Text("Charging Mode")
426
+                        .font(.subheadline.weight(.semibold))
269 427
 
270
-                    VStack(alignment: .leading, spacing: 10) {
271
-                        HStack {
272
-                            Text("Wireless Charger")
273
-                                .font(.subheadline.weight(.semibold))
274
-                            Spacer()
275
-                            Button(selectedCharger == nil ? "Select" : "Change") {
276
-                                chargerLibraryVisibility = true
277
-                            }
428
+                    Picker("Charging Mode", selection: $draftChargingStateMode) {
429
+                        ForEach(chargedDevice.supportedChargingStateModes) { chargingStateMode in
430
+                            Text(chargingStateMode.title)
431
+                                .tag(Optional(chargingStateMode))
278 432
                         }
433
+                    }
434
+                    .pickerStyle(.segmented)
279 435
 
280
-                        if let selectedCharger {
281
-                            HStack(alignment: .top, spacing: 12) {
282
-                                ChargedDeviceQRCodeView(
283
-                                    qrIdentifier: selectedCharger.qrIdentifier,
284
-                                    side: 62
285
-                                )
286
-
287
-                                VStack(alignment: .leading, spacing: 6) {
288
-                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
289
-                                        .font(.subheadline.weight(.semibold))
290
-
291
-                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
292
-                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
293
-                                            .font(.caption)
294
-                                            .foregroundColor(.secondary)
295
-                                    }
296
-
297
-                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
298
-                                        Text(
299
-                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
300
-                                                .map { "\($0.format(decimalDigits: 1)) V" }
301
-                                                .joined(separator: ", ")
302
-                                        )
303
-                                        .font(.caption2)
304
-                                        .foregroundColor(.secondary)
305
-                                    }
306
-                                }
307
-                            }
308
-                        } else {
309
-                            Text("Wireless sessions need a selected charger in addition to the charged device.")
310
-                                .font(.caption)
311
-                                .foregroundColor(.secondary)
312
-                        }
436
+                    if draftChargingStateMode == nil {
437
+                        Text("Pick whether the device is on or off for this session.")
438
+                            .font(.caption2)
439
+                            .foregroundColor(.orange)
313 440
                     }
314 441
                 }
442
+            } else if let chargingStateMode = chargedDevice.supportedChargingStateModes.first {
443
+                Label(
444
+                    "Charging mode: \(chargingStateMode.title)",
445
+                    systemImage: chargingStateMode == .off ? "power.circle" : "power"
446
+                )
447
+                .font(.subheadline.weight(.semibold))
448
+            }
449
+
450
+            VStack(alignment: .leading, spacing: 8) {
451
+                Text("Initial Checkpoint")
452
+                    .font(.subheadline.weight(.semibold))
453
+
454
+                TextField("Battery %", text: $initialCheckpoint)
455
+                    .keyboardType(.decimalPad)
315 456
 
316
-                Button("Add Battery Checkpoint") {
317
-                    checkpointEditorVisibility = true
457
+                Text("The session starts only after this first checkpoint is recorded.")
458
+                    .font(.caption2)
459
+                    .foregroundColor(.secondary)
460
+            }
461
+
462
+            Toggle("Auto-stop when the type already has a stop threshold", isOn: $draftAutoStopEnabled)
463
+
464
+            Text(draftAutoStopDescription)
465
+                .font(.footnote)
466
+                .foregroundColor(setupWirelessThresholdWarning == nil ? .secondary : .orange)
467
+
468
+            Button("Start Session") {
469
+                startSession()
470
+            }
471
+            .frame(maxWidth: .infinity)
472
+            .padding(.vertical, 10)
473
+            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
474
+            .buttonStyle(.plain)
475
+            .disabled(!canStartSession)
476
+        }
477
+    }
478
+
479
+    private var wirelessChargerSection: some View {
480
+        VStack(alignment: .leading, spacing: 10) {
481
+            HStack {
482
+                Text("Wireless Charger")
483
+                    .font(.subheadline.weight(.semibold))
484
+                Spacer()
485
+                Button(selectedCharger == nil ? "Select" : "Change") {
486
+                    chargerLibraryVisibility = true
318 487
                 }
319
-                .frame(maxWidth: .infinity)
320
-                .padding(.vertical, 10)
321
-                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
322
-                .buttonStyle(.plain)
488
+                .disabled(openChargeSession != nil)
489
+            }
323 490
 
324
-                Button("Edit Device") {
325
-                    editingChargedDevice = selectedChargedDevice
491
+            if let selectedCharger {
492
+                HStack(alignment: .top, spacing: 12) {
493
+                    ChargedDeviceQRCodeView(
494
+                        qrIdentifier: selectedCharger.qrIdentifier,
495
+                        side: 62
496
+                    )
497
+
498
+                    VStack(alignment: .leading, spacing: 6) {
499
+                        Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
500
+                            .font(.subheadline.weight(.semibold))
501
+
502
+                        if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
503
+                            Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
504
+                                .font(.caption)
505
+                                .foregroundColor(.secondary)
506
+                        }
507
+
508
+                        if let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps {
509
+                            Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A")
510
+                                .font(.caption2)
511
+                                .foregroundColor(.secondary)
512
+                        } else {
513
+                            Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.")
514
+                                .font(.caption2)
515
+                                .foregroundColor(.orange)
516
+                        }
517
+                    }
326 518
                 }
327
-                .frame(maxWidth: .infinity)
328
-                .padding(.vertical, 10)
329
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
330
-                .buttonStyle(.plain)
331 519
             } else {
332
-                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.")
333
-                    .font(.footnote)
520
+                Text("Wireless sessions need a selected charger in addition to the charged device.")
521
+                    .font(.caption)
334 522
                     .foregroundColor(.secondary)
335 523
             }
336 524
         }
337
-        .padding(18)
338
-        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
339 525
     }
340 526
 
341
-    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
527
+    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
342 528
         VStack(alignment: .leading, spacing: 12) {
343 529
             Text("Charging Monitor")
344 530
                 .font(.headline)
345 531
 
346 532
             ChargeRecordMetricsTableView(
347
-                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
533
+                labels: ["Type", "Mode", "Energy", "Auto Stop"],
348 534
                 values: [
349
-                    activeChargeSession.sourceMode.title,
350
-                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
351
-                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
352
-                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
535
+                    openChargeSession.chargingTransportMode.title,
536
+                    openChargeSession.chargingStateMode.title,
537
+                    "\(openChargeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh",
538
+                    autoStopLabel(for: openChargeSession)
353 539
                 ]
354 540
             )
355 541
 
356
-            if let selectedChargedDevice,
357
-               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
542
+            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(for: openChargeSession) {
358 543
                 VStack(alignment: .leading, spacing: 4) {
359 544
                     Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
360 545
                         .font(.caption.weight(.semibold))
@@ -366,7 +551,23 @@ struct MeterChargeRecordTabView: View {
366 551
                 }
367 552
             }
368 553
 
369
-            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
554
+            if let sessionWarning = sessionWarning(for: openChargeSession) {
555
+                Text(sessionWarning)
556
+                    .font(.caption)
557
+                    .foregroundColor(.orange)
558
+            }
559
+
560
+            if openChargeSession.isPaused {
561
+                Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.")
562
+                    .font(.caption)
563
+                    .foregroundColor(.secondary)
564
+            }
565
+
566
+            if openChargeSession.requiresCompletionConfirmation {
567
+                completionConfirmationCard(openChargeSession)
568
+            }
569
+
570
+            if let targetBatteryPercent = openChargeSession.targetBatteryPercent {
370 571
                 Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
371 572
                     .font(.caption.weight(.semibold))
372 573
             } else {
@@ -375,7 +576,15 @@ struct MeterChargeRecordTabView: View {
375 576
                     .foregroundColor(.secondary)
376 577
             }
377 578
 
378
-            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
579
+            Button("Add Battery Checkpoint") {
580
+                checkpointEditorVisibility = true
581
+            }
582
+            .frame(maxWidth: .infinity)
583
+            .padding(.vertical, 10)
584
+            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
585
+            .buttonStyle(.plain)
586
+
587
+            Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
379 588
                 targetNotificationEditorVisibility = true
380 589
             }
381 590
             .frame(maxWidth: .infinity)
@@ -383,9 +592,9 @@ struct MeterChargeRecordTabView: View {
383 592
             .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
384 593
             .buttonStyle(.plain)
385 594
 
386
-            if activeChargeSession.targetBatteryPercent != nil {
595
+            if openChargeSession.targetBatteryPercent != nil {
387 596
                 Button("Clear Target Notification") {
388
-                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
597
+                    _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
389 598
                 }
390 599
                 .frame(maxWidth: .infinity)
391 600
                 .padding(.vertical, 10)
@@ -393,41 +602,43 @@ struct MeterChargeRecordTabView: View {
393 602
                 .buttonStyle(.plain)
394 603
             }
395 604
 
396
-            if activeChargeSession.requiresCompletionConfirmation {
397
-                completionConfirmationCard(activeChargeSession)
398
-            }
399
-
400
-            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
401
-                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
402
-                    .font(.caption.weight(.semibold))
605
+            if openChargeSession.status == .active {
606
+                Button("Pause Session") {
607
+                    _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
608
+                }
609
+                .frame(maxWidth: .infinity)
610
+                .padding(.vertical, 10)
611
+                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
612
+                .buttonStyle(.plain)
613
+            } else if openChargeSession.status == .paused {
614
+                Button("Resume Session") {
615
+                    _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
616
+                }
617
+                .frame(maxWidth: .infinity)
618
+                .padding(.vertical, 10)
619
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
620
+                .buttonStyle(.plain)
403 621
             }
404 622
 
405
-            Label(
406
-                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
407
-                systemImage: activeChargeSession.chargingTransportMode.symbolName
408
-            )
409
-            .font(.caption)
410
-            .foregroundColor(.secondary)
411
-
412
-            if activeChargeSession.chargingTransportMode == .wireless {
413
-                if let chargerID = activeChargeSession.chargerID,
414
-                   let charger = appData.chargedDeviceSummary(id: chargerID) {
415
-                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
416
-                        .font(.caption)
417
-                        .foregroundColor(.secondary)
418
-                } else {
419
-                    Text("No wireless charger is currently selected for this session.")
420
-                        .font(.caption)
421
-                        .foregroundColor(.orange)
422
-                }
623
+            Button("Stop Session") {
624
+                pendingStopRequest = ChargeSessionStopRequest(
625
+                    sessionID: openChargeSession.id,
626
+                    title: "Stop Session",
627
+                    confirmTitle: "Stop",
628
+                    explanation: "Record the final battery checkpoint before closing this session."
629
+                )
423 630
             }
631
+            .frame(maxWidth: .infinity)
632
+            .padding(.vertical, 10)
633
+            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
634
+            .buttonStyle(.plain)
424 635
 
425
-            if activeChargeSession.checkpoints.isEmpty == false {
636
+            if !openChargeSession.checkpoints.isEmpty {
426 637
                 VStack(alignment: .leading, spacing: 8) {
427 638
                     Text("Battery Checkpoints")
428 639
                         .font(.subheadline.weight(.semibold))
429 640
 
430
-                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
641
+                    ForEach(openChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
431 642
                         HStack {
432 643
                             Text(checkpoint.timestamp.format())
433 644
                                 .font(.caption2)
@@ -445,7 +656,7 @@ struct MeterChargeRecordTabView: View {
445 656
                 }
446 657
             }
447 658
 
448
-            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.")
659
+            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.")
449 660
                 .font(.footnote)
450 661
                 .foregroundColor(.secondary)
451 662
         }
@@ -453,29 +664,28 @@ struct MeterChargeRecordTabView: View {
453 664
         .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
454 665
     }
455 666
 
456
-    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
667
+    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
457 668
         VStack(alignment: .leading, spacing: 10) {
458 669
             Text("Completion Needs Confirmation")
459 670
                 .font(.subheadline.weight(.semibold))
460 671
 
461
-            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
462
-                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
672
+            if let contradictionPercent = openChargeSession.completionContradictionPercent {
673
+                Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
463 674
                     .font(.caption)
464 675
                     .foregroundColor(.secondary)
465 676
             } else {
466
-                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
677
+                Text("Current dropped to the learned stop threshold, but the battery prediction does not look like a normal finish yet.")
467 678
                     .font(.caption)
468 679
                     .foregroundColor(.secondary)
469 680
             }
470 681
 
471
-            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
472
-                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
473
-                    .font(.caption2)
474
-                    .foregroundColor(.secondary)
475
-            }
476
-
477
-            Button("Finish Session") {
478
-                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
682
+            Button("Finish Session With Final Checkpoint") {
683
+                pendingStopRequest = ChargeSessionStopRequest(
684
+                    sessionID: openChargeSession.id,
685
+                    title: "Finish Session",
686
+                    confirmTitle: "Finish",
687
+                    explanation: "Add the final checkpoint before confirming the stop."
688
+                )
479 689
             }
480 690
             .frame(maxWidth: .infinity)
481 691
             .padding(.vertical, 10)
@@ -483,7 +693,7 @@ struct MeterChargeRecordTabView: View {
483 693
             .buttonStyle(.plain)
484 694
 
485 695
             Button("Keep Monitoring") {
486
-                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
696
+                _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
487 697
             }
488 698
             .frame(maxWidth: .infinity)
489 699
             .padding(.vertical, 10)
@@ -494,22 +704,248 @@ struct MeterChargeRecordTabView: View {
494 704
         .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
495 705
     }
496 706
 
497
-    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
498
-        Binding(
499
-            get: {
500
-                effectiveChargingTransportMode(for: chargedDevice)
501
-            },
502
-            set: { newValue in
503
-                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
707
+    private func sessionChartCard(
708
+        timeRange: ClosedRange<Date>,
709
+        session: ChargeSessionSummary
710
+    ) -> some View {
711
+        VStack(alignment: .leading, spacing: 12) {
712
+            Text("Session Chart")
713
+                .font(.headline)
714
+
715
+            MeasurementChartView(timeRange: timeRange)
716
+                .environmentObject(usbMeter.measurements)
717
+                .frame(minHeight: 220)
718
+
719
+            Text("The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging.")
720
+                .font(.footnote)
721
+                .foregroundColor(.secondary)
722
+        }
723
+        .padding(18)
724
+        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
725
+    }
726
+
727
+    private var meterTotalsCard: some View {
728
+        VStack(alignment: .leading, spacing: 12) {
729
+            Text("Meter Totals")
730
+                .font(.headline)
731
+
732
+            ChargeRecordMetricsTableView(
733
+                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
734
+                values: [
735
+                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
736
+                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
737
+                    usbMeter.recordingDurationDescription,
738
+                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
739
+                ]
740
+            )
741
+
742
+            Text("These values come directly from the meter and remain separate from the explicit app session controls.")
743
+                .font(.footnote)
744
+                .foregroundColor(.secondary)
745
+
746
+            if usbMeter.supportsDataGroupCommands {
747
+                Button("Reset Active Group") {
748
+                    usbMeter.clear()
749
+                }
750
+                .frame(maxWidth: .infinity)
751
+                .padding(.vertical, 10)
752
+                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
753
+                .buttonStyle(.plain)
504 754
             }
755
+        }
756
+        .padding(18)
757
+        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
758
+    }
759
+
760
+    private var showsWirelessChargerSection: Bool {
761
+        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
762
+        return transportMode == .wireless
763
+    }
764
+
765
+    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
766
+        if session.autoStopEnabled == false {
767
+            return "Manual"
768
+        }
769
+        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
770
+            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
771
+        }
772
+        if session.stopThresholdAmps > 0 {
773
+            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
774
+        }
775
+        return "Learning"
776
+    }
777
+
778
+    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
779
+        guard session.chargingTransportMode == .wireless,
780
+              let chargerID = session.chargerID,
781
+              let charger = appData.chargedDeviceSummary(id: chargerID) else {
782
+            return nil
783
+        }
784
+
785
+        guard charger.chargerIdleCurrentAmps == nil else {
786
+            return nil
787
+        }
788
+
789
+        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
790
+    }
791
+
792
+    private func startSession() {
793
+        guard let selectedChargedDevice,
794
+              let chargingTransportMode = selectedDraftTransportMode,
795
+              let chargingStateMode = selectedDraftChargingStateMode,
796
+              let initialCheckpointValue else {
797
+            return
798
+        }
799
+
800
+        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
801
+        let didStart = appData.startChargeSession(
802
+            for: usbMeter,
803
+            chargedDeviceID: selectedChargedDevice.id,
804
+            chargerID: chargerID,
805
+            chargingTransportMode: chargingTransportMode,
806
+            chargingStateMode: chargingStateMode,
807
+            autoStopEnabled: draftAutoStopEnabled,
808
+            initialBatteryPercent: initialCheckpointValue
505 809
         )
810
+
811
+        if didStart {
812
+            initialCheckpoint = ""
813
+        }
506 814
     }
507 815
 
508
-    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
509
-        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
816
+    private func syncDraftSelections() {
817
+        guard let selectedChargedDevice else {
818
+            draftChargingTransportMode = nil
819
+            draftChargingStateMode = nil
820
+            draftAutoStopEnabled = true
821
+            return
822
+        }
823
+
824
+        if let openChargeSession {
825
+            draftChargingTransportMode = openChargeSession.chargingTransportMode
826
+            draftChargingStateMode = openChargeSession.chargingStateMode
827
+            draftAutoStopEnabled = openChargeSession.autoStopEnabled
828
+            return
829
+        }
830
+
831
+        if let draftChargingTransportMode,
832
+           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
833
+            self.draftChargingTransportMode = nil
834
+        }
835
+
836
+        if let draftChargingStateMode,
837
+           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
838
+            self.draftChargingStateMode = nil
839
+        }
840
+
841
+        if selectedChargedDevice.supportedChargingModes.count == 1 {
842
+            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
843
+        }
844
+
845
+        if selectedChargedDevice.supportedChargingStateModes.count == 1 {
846
+            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
847
+        }
510 848
     }
849
+}
850
+
851
+struct ChargeSessionCompletionSheetView: View {
852
+    @EnvironmentObject private var appData: AppData
853
+    @Environment(\.dismiss) private var dismiss
854
+
855
+    let sessionID: UUID
856
+    let title: String
857
+    let confirmTitle: String
858
+    let explanation: String
859
+
860
+    @State private var batteryPercent = ""
861
+    @State private var label = "Final"
862
+
863
+    var body: some View {
864
+        NavigationView {
865
+            Form {
866
+                Section(header: Text("Final Checkpoint")) {
867
+                    TextField("Battery %", text: $batteryPercent)
868
+                        .keyboardType(.decimalPad)
869
+                    TextField("Label", text: $label)
870
+                }
871
+
872
+                Section {
873
+                    Text(explanation)
874
+                        .font(.footnote)
875
+                        .foregroundColor(.secondary)
876
+
877
+                    if let sessionWarning {
878
+                        Text(sessionWarning)
879
+                            .font(.footnote)
880
+                            .foregroundColor(.orange)
881
+                    } else if (parsedBatteryPercent ?? 0) >= 99.5 {
882
+                        Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
883
+                            .font(.footnote)
884
+                            .foregroundColor(.secondary)
885
+                    }
886
+                }
887
+            }
888
+            .navigationTitle(title)
889
+            .navigationBarTitleDisplayMode(.inline)
890
+            .toolbar {
891
+                ToolbarItem(placement: .cancellationAction) {
892
+                    Button("Cancel") {
893
+                        dismiss()
894
+                    }
895
+                }
896
+                ToolbarItem(placement: .confirmationAction) {
897
+                    Button(confirmTitle) {
898
+                        guard let batteryPercent = parsedBatteryPercent else {
899
+                            return
900
+                        }
901
+
902
+                        if appData.stopChargeSession(
903
+                            sessionID: sessionID,
904
+                            finalBatteryPercent: batteryPercent,
905
+                            label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label
906
+                        ) {
907
+                            dismiss()
908
+                        }
909
+                    }
910
+                    .disabled(parsedBatteryPercent == nil)
911
+                }
912
+            }
913
+        }
914
+        .navigationViewStyle(StackNavigationViewStyle())
915
+    }
916
+
917
+    private var parsedBatteryPercent: Double? {
918
+        let normalized = batteryPercent
919
+            .trimmingCharacters(in: .whitespacesAndNewlines)
920
+            .replacingOccurrences(of: ",", with: ".")
921
+        guard let value = Double(normalized), value >= 0, value <= 100 else {
922
+            return nil
923
+        }
924
+        return value
925
+    }
926
+
927
+    private var sessionWarning: String? {
928
+        guard let session = appData.chargedDevices
929
+            .flatMap(\.sessions)
930
+            .first(where: { $0.id == sessionID }),
931
+              session.chargingTransportMode == .wireless,
932
+              let chargerID = session.chargerID,
933
+              let charger = appData.chargedDeviceSummary(id: chargerID),
934
+              charger.chargerIdleCurrentAmps == nil else {
935
+            return nil
936
+        }
937
+
938
+        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."
939
+    }
940
+}
941
+
942
+private struct ChargeSessionStopRequest: Identifiable {
943
+    let sessionID: UUID
944
+    let title: String
945
+    let confirmTitle: String
946
+    let explanation: String
511 947
 
512
-    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
513
-        effectiveChargingTransportMode(for: chargedDevice) == .wireless
948
+    var id: UUID {
949
+        sessionID
514 950
     }
515 951
 }