Showing 5 changed files with 472 additions and 222 deletions
+106 -11
USB Meter/Model/AppData.swift
@@ -42,6 +42,7 @@ final class AppData : ObservableObject {
42 42
     private var chargeInsightsStoreObserver: AnyCancellable?
43 43
     private var chargeInsightsRemoteObserver: AnyCancellable?
44 44
     private var chargerStandbyPowerStoreObserver: AnyCancellable?
45
+    private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
45 46
     private let meterStore = MeterNameStore.shared
46 47
     private var chargeInsightsStore: ChargeInsightsStore?
47 48
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
@@ -103,7 +104,7 @@ final class AppData : ObservableObject {
103 104
         )
104 105
         .receive(on: DispatchQueue.main)
105 106
         .sink { [weak self] _ in
106
-            self?.reloadChargedDevices()
107
+            self?.scheduleChargedDevicesReload()
107 108
         }
108 109
 
109 110
         chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
@@ -112,7 +113,7 @@ final class AppData : ObservableObject {
112 113
         )
113 114
         .receive(on: DispatchQueue.main)
114 115
         .sink { [weak self] _ in
115
-            self?.reloadChargedDevices()
116
+            self?.scheduleChargedDevicesReload()
116 117
         }
117 118
 
118 119
         chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
@@ -194,7 +195,7 @@ final class AppData : ObservableObject {
194 195
     func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
195 196
         let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
196 197
 
197
-        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
198
+        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
198 199
            let liveDevice = chargedDevices.first(where: {
199 200
                $0.id == activeSession.chargedDeviceID && $0.isCharger == false
200 201
            }) {
@@ -209,7 +210,7 @@ final class AppData : ObservableObject {
209 210
     func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
210 211
         let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
211 212
 
212
-        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
213
+        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
213 214
            let chargerID = activeSession.chargerID,
214 215
            let liveCharger = chargedDevices.first(where: {
215 216
                $0.id == chargerID && $0.isCharger
@@ -223,7 +224,10 @@ final class AppData : ObservableObject {
223 224
     }
224 225
 
225 226
     func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
226
-        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
227
+        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
228
+            return cachedSummary
229
+        }
230
+        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
227 231
     }
228 232
 
229 233
     func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
@@ -557,14 +561,42 @@ final class AppData : ObservableObject {
557 561
         return didSave
558 562
     }
559 563
 
564
+    @discardableResult
565
+    func addBatteryCheckpoint(
566
+        percent: Double,
567
+        label: String?,
568
+        for sessionID: UUID,
569
+        measuredEnergyWh: Double?,
570
+        measuredChargeAh: Double?
571
+    ) -> Bool {
572
+        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
573
+            percent: percent,
574
+            label: label,
575
+            for: sessionID,
576
+            measuredEnergyWh: measuredEnergyWh,
577
+            measuredChargeAh: measuredChargeAh
578
+        ) ?? false
579
+
580
+        if didSave {
581
+            reloadChargedDevices()
582
+        }
583
+
584
+        return didSave
585
+    }
586
+
560 587
     func batteryCheckpointPlausibilityWarning(
561 588
         percent: Double,
562
-        for sessionID: UUID
589
+        for sessionID: UUID,
590
+        effectiveEnergyWhOverride: Double? = nil
563 591
     ) -> BatteryCheckpointPlausibilityWarning? {
564 592
         guard let session = chargeSessionSummary(id: sessionID) else {
565 593
             return nil
566 594
         }
567
-        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
595
+        return batteryCheckpointPlausibilityWarning(
596
+            percent: percent,
597
+            for: session,
598
+            effectiveEnergyWhOverride: effectiveEnergyWhOverride
599
+        )
568 600
     }
569 601
 
570 602
     @discardableResult
@@ -575,7 +607,7 @@ final class AppData : ObservableObject {
575 607
         ) ?? false
576 608
 
577 609
         if didDelete {
578
-            reloadChargedDevices()
610
+            scheduleChargedDevicesReload(delay: 0)
579 611
         }
580 612
 
581 613
         return didDelete
@@ -756,7 +788,32 @@ final class AppData : ObservableObject {
756 788
         }
757 789
     }
758 790
 
791
+    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
792
+        pendingChargedDevicesReloadWorkItem?.cancel()
793
+
794
+        let workItem = DispatchWorkItem { [weak self] in
795
+            self?.reloadChargedDevices()
796
+        }
797
+        pendingChargedDevicesReloadWorkItem = workItem
798
+        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
799
+    }
800
+
801
+    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
802
+        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
803
+        guard !normalizedMAC.isEmpty else {
804
+            return nil
805
+        }
806
+
807
+        return chargedDevices
808
+            .lazy
809
+            .compactMap(\.activeSession)
810
+            .first(where: { $0.status == .active && $0.meterMACAddress == normalizedMAC })
811
+    }
812
+
759 813
     private func reloadChargedDevices() {
814
+        pendingChargedDevicesReloadWorkItem?.cancel()
815
+        pendingChargedDevicesReloadWorkItem = nil
816
+
760 817
         let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
761 818
         chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
762 819
             chargedDevice.withStandbyPowerMeasurements(
@@ -823,7 +880,8 @@ final class AppData : ObservableObject {
823 880
 
824 881
     private func batteryCheckpointPlausibilityWarning(
825 882
         percent: Double,
826
-        for session: ChargeSessionSummary
883
+        for session: ChargeSessionSummary,
884
+        effectiveEnergyWhOverride: Double? = nil
827 885
     ) -> BatteryCheckpointPlausibilityWarning? {
828 886
         guard percent.isFinite, percent >= 0, percent <= 100 else {
829 887
             return nil
@@ -847,8 +905,46 @@ final class AppData : ObservableObject {
847 905
             )
848 906
         }
849 907
 
908
+        let effectiveEnergyWh = effectiveEnergyWhOverride
909
+            ?? session.effectiveBatteryEnergyWh
910
+            ?? session.measuredEnergyWh
911
+
912
+        if let lastCheckpoint = sortedCheckpoints.last,
913
+           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
914
+            let estimatedCapacityWh = session.capacityEstimateWh
915
+                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
916
+                ?? chargedDevice.estimatedBatteryCapacityWh
917
+
918
+            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
919
+                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
920
+                let expectedPercent = min(
921
+                    100,
922
+                    max(
923
+                        lastCheckpoint.batteryPercent,
924
+                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
925
+                    )
926
+                )
927
+                let predictionGap = percent - expectedPercent
928
+                guard abs(predictionGap) >= 4 else {
929
+                    return nil
930
+                }
931
+
932
+                let direction = predictionGap > 0 ? "above" : "below"
933
+                let gapText = abs(predictionGap).format(decimalDigits: 0)
934
+                let expectedText = expectedPercent.format(decimalDigits: 0)
935
+
936
+                return BatteryCheckpointPlausibilityWarning(
937
+                    title: "Checkpoint Looks Implausible",
938
+                    message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
939
+                )
940
+            }
941
+        }
942
+
850 943
         guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
851
-              let prediction = chargedDevice.batteryLevelPrediction(for: session)
944
+              let prediction = chargedDevice.batteryLevelPrediction(
945
+                for: session,
946
+                effectiveEnergyWhOverride: effectiveEnergyWh
947
+              )
852 948
         else {
853 949
             return nil
854 950
         }
@@ -863,7 +959,6 @@ final class AppData : ObservableObject {
863 959
         let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
864 960
 
865 961
         if let lastCheckpoint = sortedCheckpoints.last {
866
-            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
867 962
             let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
868 963
             return BatteryCheckpointPlausibilityWarning(
869 964
                 title: "Checkpoint Looks Implausible",
+92 -11
USB Meter/Model/ChargeInsightsStore.swift
@@ -581,7 +581,9 @@ final class ChargeInsightsStore {
581 581
     func addBatteryCheckpoint(
582 582
         percent: Double,
583 583
         label: String?,
584
-        for sessionID: UUID
584
+        for sessionID: UUID,
585
+        measuredEnergyWh: Double? = nil,
586
+        measuredChargeAh: Double? = nil
585 587
     ) -> Bool {
586 588
         guard percent.isFinite, percent >= 0, percent <= 100 else {
587 589
             return false
@@ -593,7 +595,13 @@ final class ChargeInsightsStore {
593 595
                 return
594 596
             }
595 597
 
596
-            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
598
+            didSave = addBatteryCheckpoint(
599
+                percent: percent,
600
+                label: label,
601
+                measuredEnergyWh: measuredEnergyWh,
602
+                measuredChargeAh: measuredChargeAh,
603
+                to: session
604
+            )
597 605
         }
598 606
         return didSave
599 607
     }
@@ -617,16 +625,11 @@ final class ChargeInsightsStore {
617 625
             context.delete(checkpoint)
618 626
             refreshCheckpointDerivedValues(for: session)
619 627
 
620
-            guard saveContext() else {
621
-                return
622
-            }
623
-
624 628
             if let chargedDeviceID {
625 629
                 refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
626
-                didSave = saveContext()
627
-            } else {
628
-                didSave = true
629 630
             }
631
+
632
+            didSave = saveContext()
630 633
         }
631 634
         return didSave
632 635
     }
@@ -856,12 +859,18 @@ final class ChargeInsightsStore {
856 859
             let devices = fetchObjects(entityName: EntityName.chargedDevice)
857 860
             let sessions = fetchObjects(entityName: EntityName.chargeSession)
858 861
             let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
859
-            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
860 862
 
861 863
             let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
862
-            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
863 864
             let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
864 865
             let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
866
+            let sampleBackedSessionIDs = sampleBackedSessionIDs(
867
+                devices: devices,
868
+                sessionsByDeviceID: sessionsByDeviceID,
869
+                sessionsByChargerID: sessionsByChargerID
870
+            )
871
+            let samplesBySessionID = Dictionary(
872
+                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
873
+            ) { stringValue($0, key: "sessionID") ?? "" }
865 874
 
866 875
             summaries = devices.compactMap { device in
867 876
                 guard
@@ -2111,6 +2120,16 @@ final class ChargeInsightsStore {
2111 2120
         return (try? context.fetch(request)) ?? []
2112 2121
     }
2113 2122
 
2123
+    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2124
+        guard !sessionIDs.isEmpty else {
2125
+            return []
2126
+        }
2127
+
2128
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2129
+        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2130
+        return (try? context.fetch(request)) ?? []
2131
+    }
2132
+
2114 2133
     private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2115 2134
         let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2116 2135
         request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
@@ -2139,6 +2158,68 @@ final class ChargeInsightsStore {
2139 2158
         return (try? context.fetch(request)) ?? []
2140 2159
     }
2141 2160
 
2161
+    private func sampleBackedSessionIDs(
2162
+        devices: [NSManagedObject],
2163
+        sessionsByDeviceID: [String: [NSManagedObject]],
2164
+        sessionsByChargerID: [String: [NSManagedObject]]
2165
+    ) -> Set<String> {
2166
+        var sessionIDs: Set<String> = []
2167
+
2168
+        for device in devices {
2169
+            guard
2170
+                let deviceID = stringValue(device, key: "id"),
2171
+                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2172
+                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2173
+            else {
2174
+                continue
2175
+            }
2176
+
2177
+            let relevantSessions = relevantSessionObjects(
2178
+                for: deviceID,
2179
+                deviceClass: deviceClass,
2180
+                sessionsByDeviceID: sessionsByDeviceID,
2181
+                sessionsByChargerID: sessionsByChargerID
2182
+            )
2183
+            .sorted { lhs, rhs in
2184
+                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2185
+                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2186
+
2187
+                if lhsStatus.isOpen && !rhsStatus.isOpen {
2188
+                    return true
2189
+                }
2190
+                if !lhsStatus.isOpen && rhsStatus.isOpen {
2191
+                    return false
2192
+                }
2193
+
2194
+                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2195
+                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2196
+            }
2197
+
2198
+            var recentCompletedSamplesIncluded = 0
2199
+
2200
+            for session in relevantSessions {
2201
+                guard let sessionID = stringValue(session, key: "id"),
2202
+                      let status = statusValue(session, key: "statusRawValue") else {
2203
+                    continue
2204
+                }
2205
+
2206
+                if status.isOpen {
2207
+                    sessionIDs.insert(sessionID)
2208
+                    continue
2209
+                }
2210
+
2211
+                guard recentCompletedSamplesIncluded < 2 else {
2212
+                    continue
2213
+                }
2214
+
2215
+                sessionIDs.insert(sessionID)
2216
+                recentCompletedSamplesIncluded += 1
2217
+            }
2218
+        }
2219
+
2220
+        return sessionIDs
2221
+    }
2222
+
2142 2223
     private func relevantSessionObjects(
2143 2224
         for chargedDeviceID: String,
2144 2225
         deviceClass: ChargedDeviceClass,
+134 -64
USB Meter/Views/ChargedDevices/BatteryCheckpointEditorSheetView.swift
@@ -7,99 +7,169 @@
7 7
 
8 8
 import SwiftUI
9 9
 
10
-struct BatteryCheckpointEditorSheetView: View {
10
+struct BatteryCheckpointEditorContentView: View {
11 11
     @EnvironmentObject private var appData: AppData
12
-    @EnvironmentObject private var meter: Meter
13
-    @Environment(\.dismiss) private var dismiss
12
+
13
+    let sessionID: UUID
14
+    let message: String
15
+    let effectiveEnergyWhOverride: Double?
16
+    let measuredChargeAhOverride: Double?
17
+    let onCancel: (() -> Void)?
18
+    let onSaved: (() -> Void)?
14 19
 
15 20
     @State private var batteryPercent = ""
16 21
     @State private var label = ""
17
-    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
18
-
19
-    private var activeSession: ChargeSessionSummary? {
20
-        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
21
-    }
22
+    @State private var showsWarningPopover = false
22 23
 
23 24
     private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
24
-        guard let percent = Double(batteryPercent),
25
-              let activeSession else {
25
+        guard let percent = normalizedBatteryPercent else {
26 26
             return nil
27 27
         }
28
-        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: activeSession.id)
28
+        return appData.batteryCheckpointPlausibilityWarning(
29
+            percent: percent,
30
+            for: sessionID,
31
+            effectiveEnergyWhOverride: effectiveEnergyWhOverride
32
+        )
29 33
     }
30 34
 
31
-    var body: some View {
32
-        NavigationView {
33
-            Form {
34
-                Section(
35
-                    header: ContextInfoHeader(
36
-                        title: "Checkpoint",
37
-                        message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve."
38
-                    )
39
-                ) {
40
-                    TextField("Battery %", text: $batteryPercent)
41
-                        .keyboardType(.decimalPad)
42
-                    TextField("Label (optional)", text: $label)
43
-                }
35
+    private var normalizedBatteryPercent: Double? {
36
+        let normalized = batteryPercent
37
+            .trimmingCharacters(in: .whitespacesAndNewlines)
38
+            .replacingOccurrences(of: ",", with: ".")
39
+        return Double(normalized)
40
+    }
44 41
 
42
+    private var canSave: Bool {
43
+        guard let percent = normalizedBatteryPercent else {
44
+            return false
45
+        }
46
+        return percent >= 0 && percent <= 100
47
+    }
48
+
49
+    var body: some View {
50
+        VStack(alignment: .leading, spacing: 12) {
51
+            HStack(spacing: 8) {
52
+                Text("Checkpoint")
53
+                Spacer(minLength: 0)
45 54
                 if let plausibilityWarning {
46
-                    Section(header: Text(plausibilityWarning.title)) {
47
-                        Text(plausibilityWarning.message)
48
-                            .font(.footnote)
55
+                    Button {
56
+                        showsWarningPopover.toggle()
57
+                    } label: {
58
+                        Image(systemName: "exclamationmark.triangle.fill")
59
+                            .font(.body.weight(.semibold))
49 60
                             .foregroundColor(.orange)
50 61
                     }
62
+                    .buttonStyle(.plain)
63
+                    .accessibilityLabel(plausibilityWarning.title)
64
+                    .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
65
+                        VStack(alignment: .leading, spacing: 10) {
66
+                            Text(plausibilityWarning.title)
67
+                                .font(.headline)
68
+                            Text(plausibilityWarning.message)
69
+                                .font(.body)
70
+                                .fixedSize(horizontal: false, vertical: true)
71
+                        }
72
+                        .padding(16)
73
+                        .frame(width: 320, alignment: .leading)
74
+                    }
51 75
                 }
76
+                ContextInfoButton(
77
+                    title: "Checkpoint",
78
+                    message: message
79
+                )
52 80
             }
53
-            .navigationTitle("Battery Checkpoint")
54
-            .navigationBarTitleDisplayMode(.inline)
55
-            .toolbar {
56
-                ToolbarItem(placement: .cancellationAction) {
81
+
82
+            VStack(alignment: .leading, spacing: 10) {
83
+                TextField("Battery %", text: $batteryPercent)
84
+                    .keyboardType(.decimalPad)
85
+                    .textFieldStyle(.roundedBorder)
86
+
87
+                TextField("Label (optional)", text: $label)
88
+                    .textFieldStyle(.roundedBorder)
89
+            }
90
+
91
+            HStack(spacing: 10) {
92
+                if let onCancel {
57 93
                     Button("Cancel") {
58
-                        dismiss()
94
+                        onCancel()
59 95
                     }
96
+                    .frame(maxWidth: .infinity)
97
+                    .padding(.vertical, 10)
98
+                    .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
99
+                    .buttonStyle(.plain)
60 100
                 }
61
-                ToolbarItem(placement: .confirmationAction) {
62
-                    Button("Save") {
63
-                        saveCheckpoint()
64
-                    }
65
-                    .disabled(
66
-                        (Double(batteryPercent) ?? -1) < 0
67
-                            || (Double(batteryPercent) ?? 101) > 100
68
-                            || appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) == nil
69
-                    )
101
+
102
+                Button("Save Checkpoint") {
103
+                    saveCheckpoint()
70 104
                 }
105
+                .frame(maxWidth: .infinity)
106
+                .padding(.vertical, 10)
107
+                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
108
+                .buttonStyle(.plain)
109
+                .disabled(!canSave)
110
+                .opacity(canSave ? 1 : 0.6)
71 111
             }
72 112
         }
73
-        .navigationViewStyle(StackNavigationViewStyle())
74
-        .alert(item: $confirmationWarning) { warning in
75
-            Alert(
76
-                title: Text(warning.title),
77
-                message: Text(warning.message),
78
-                primaryButton: .destructive(Text("Save Anyway")) {
79
-                    saveCheckpoint(forceOverride: true)
80
-                },
81
-                secondaryButton: .cancel()
82
-            )
83
-        }
84 113
     }
85 114
 
86
-    private func saveCheckpoint(forceOverride: Bool = false) {
87
-        guard let percent = Double(batteryPercent) else {
88
-            return
89
-        }
90
-
91
-        if !forceOverride, let plausibilityWarning {
92
-            confirmationWarning = plausibilityWarning
115
+    private func saveCheckpoint() {
116
+        guard let percent = normalizedBatteryPercent else {
93 117
             return
94 118
         }
95 119
 
96
-        let didSave = appData.addBatteryCheckpoint(
120
+        if appData.addBatteryCheckpoint(
97 121
             percent: percent,
98 122
             label: label,
99
-            for: meter
100
-        )
101
-        if didSave {
102
-            dismiss()
123
+            for: sessionID,
124
+            measuredEnergyWh: effectiveEnergyWhOverride,
125
+            measuredChargeAh: measuredChargeAhOverride
126
+        ) {
127
+            onSaved?()
128
+        }
129
+    }
130
+}
131
+
132
+struct BatteryCheckpointEditorSheetView: View {
133
+    @EnvironmentObject private var appData: AppData
134
+    @EnvironmentObject private var meter: Meter
135
+    @Environment(\.dismiss) private var dismiss
136
+
137
+    private var activeSession: ChargeSessionSummary? {
138
+        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
139
+    }
140
+
141
+    var body: some View {
142
+        NavigationView {
143
+            Group {
144
+                if let activeSession {
145
+                    Form {
146
+                        BatteryCheckpointEditorContentView(
147
+                            sessionID: activeSession.id,
148
+                            message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
149
+                            effectiveEnergyWhOverride: nil,
150
+                            measuredChargeAhOverride: nil,
151
+                            onCancel: { dismiss() },
152
+                            onSaved: { dismiss() }
153
+                        )
154
+                    }
155
+                } else {
156
+                    VStack(spacing: 12) {
157
+                        Image(systemName: "bolt.slash")
158
+                            .font(.title2)
159
+                            .foregroundColor(.secondary)
160
+                        Text("No Active Session")
161
+                            .font(.headline)
162
+                        Text("Start a charging session before adding a battery checkpoint.")
163
+                            .font(.footnote)
164
+                            .foregroundColor(.secondary)
165
+                            .multilineTextAlignment(.center)
166
+                    }
167
+                    .padding(24)
168
+                }
169
+            }
170
+            .navigationTitle("Battery Checkpoint")
171
+            .navigationBarTitleDisplayMode(.inline)
103 172
         }
173
+        .navigationViewStyle(StackNavigationViewStyle())
104 174
     }
105 175
 }
+69 -97
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -11,11 +11,12 @@ struct ChargedDeviceDetailView: View {
11 11
     @EnvironmentObject private var appData: AppData
12 12
     @Environment(\.dismiss) private var dismiss
13 13
     @State private var editorVisibility = false
14
-    @State private var checkpointEditorVisibility = false
15 14
     @State private var targetNotificationEditorVisibility = false
16 15
     @State private var pendingSessionDeletion: ChargeSessionSummary?
16
+    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
17 17
     @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
18 18
     @State private var deleteConfirmationVisibility = false
19
+    @State private var showsInlineCheckpointEditor = false
19 20
 
20 21
     let chargedDeviceID: UUID
21 22
 
@@ -90,12 +91,6 @@ struct ChargedDeviceDetailView: View {
90 91
                 .environmentObject(appData)
91 92
             }
92 93
         }
93
-        .sheet(isPresented: $checkpointEditorVisibility) {
94
-            if let sessionID = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id {
95
-                ChargedDeviceCheckpointEditorSheetView(sessionID: sessionID)
96
-                    .environmentObject(appData)
97
-            }
98
-        }
99 94
         .sheet(isPresented: $targetNotificationEditorVisibility) {
100 95
             if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
101 96
                 ChargedDeviceTargetNotificationEditorSheetView(
@@ -124,6 +119,19 @@ struct ChargedDeviceDetailView: View {
124 119
                 secondaryButton: .cancel()
125 120
             )
126 121
         }
122
+        .alert(item: $pendingCheckpointDeletion) { checkpoint in
123
+            Alert(
124
+                title: Text("Delete Battery Checkpoint"),
125
+                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
126
+                primaryButton: .destructive(Text("Delete")) {
127
+                    _ = appData.deleteBatteryCheckpoint(
128
+                        checkpointID: checkpoint.id,
129
+                        for: checkpoint.sessionID
130
+                    )
131
+                },
132
+                secondaryButton: .cancel()
133
+            )
134
+        }
127 135
         .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
128 136
             Button("Delete", role: .destructive) {
129 137
                 if appData.deleteChargedDevice(id: chargedDeviceID) {
@@ -134,6 +142,9 @@ struct ChargedDeviceDetailView: View {
134 142
         } message: {
135 143
             Text(deletionMessage)
136 144
         }
145
+        .onChange(of: appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id) { _ in
146
+            showsInlineCheckpointEditor = false
147
+        }
137 148
     }
138 149
 
139 150
     private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
@@ -425,14 +436,31 @@ struct ChargedDeviceDetailView: View {
425 436
                 .foregroundColor(.secondary)
426 437
             }
427 438
 
428
-            Button("Add Battery Checkpoint") {
429
-                checkpointEditorVisibility = true
439
+            if !activeSession.checkpoints.isEmpty {
440
+                checkpointList(
441
+                    checkpoints: Array(activeSession.checkpoints.suffix(6).reversed())
442
+                )
443
+            }
444
+
445
+            Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
446
+                showsInlineCheckpointEditor.toggle()
430 447
             }
431 448
             .frame(maxWidth: .infinity)
432 449
             .padding(.vertical, 10)
433 450
             .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
434 451
             .buttonStyle(.plain)
435 452
 
453
+            if showsInlineCheckpointEditor {
454
+                BatteryCheckpointEditorContentView(
455
+                    sessionID: activeSession.id,
456
+                    message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
457
+                    effectiveEnergyWhOverride: nil,
458
+                    measuredChargeAhOverride: nil,
459
+                    onCancel: { showsInlineCheckpointEditor = false },
460
+                    onSaved: { showsInlineCheckpointEditor = false }
461
+                )
462
+            }
463
+
436 464
             Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
437 465
                 targetNotificationEditorVisibility = true
438 466
             }
@@ -531,6 +559,38 @@ struct ChargedDeviceDetailView: View {
531 559
         .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
532 560
     }
533 561
 
562
+    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
563
+        VStack(alignment: .leading, spacing: 8) {
564
+            Text("Battery Checkpoints")
565
+                .font(.subheadline.weight(.semibold))
566
+
567
+            ForEach(checkpoints, id: \.id) { checkpoint in
568
+                HStack {
569
+                    Text(checkpoint.timestamp.format())
570
+                        .font(.caption2)
571
+                        .foregroundColor(.secondary)
572
+                    Spacer()
573
+                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
574
+                        .font(.caption.weight(.semibold))
575
+                    Text("•")
576
+                        .foregroundColor(.secondary)
577
+                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
578
+                        .font(.caption2)
579
+                        .foregroundColor(.secondary)
580
+                    Button {
581
+                        pendingCheckpointDeletion = checkpoint
582
+                    } label: {
583
+                        Image(systemName: "trash")
584
+                            .font(.caption.weight(.semibold))
585
+                            .foregroundColor(.red)
586
+                    }
587
+                    .buttonStyle(.plain)
588
+                    .help("Delete checkpoint")
589
+                }
590
+            }
591
+        }
592
+    }
593
+
534 594
     private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
535 595
         if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
536 596
             return activeSession
@@ -1000,94 +1060,6 @@ private struct StoredSeriesSnapshot {
1000 1060
     }
1001 1061
 }
1002 1062
 
1003
-private struct ChargedDeviceCheckpointEditorSheetView: View {
1004
-    @Environment(\.dismiss) private var dismiss
1005
-    @EnvironmentObject private var appData: AppData
1006
-
1007
-    let sessionID: UUID
1008
-
1009
-    @State private var batteryPercent = ""
1010
-    @State private var label = ""
1011
-    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
1012
-
1013
-    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
1014
-        guard let percent = Double(batteryPercent) else {
1015
-            return nil
1016
-        }
1017
-        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID)
1018
-    }
1019
-
1020
-    var body: some View {
1021
-        NavigationView {
1022
-            Form {
1023
-                Section(
1024
-                    header: ContextInfoHeader(
1025
-                        title: "Checkpoint",
1026
-                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
1027
-                    )
1028
-                ) {
1029
-                    TextField("Battery %", text: $batteryPercent)
1030
-                        .keyboardType(.decimalPad)
1031
-                    TextField("Label (optional)", text: $label)
1032
-                }
1033
-
1034
-                if let plausibilityWarning {
1035
-                    Section(header: Text(plausibilityWarning.title)) {
1036
-                        Text(plausibilityWarning.message)
1037
-                            .font(.footnote)
1038
-                            .foregroundColor(.orange)
1039
-                    }
1040
-                }
1041
-            }
1042
-            .navigationTitle("Battery Checkpoint")
1043
-            .navigationBarTitleDisplayMode(.inline)
1044
-            .toolbar {
1045
-                ToolbarItem(placement: .cancellationAction) {
1046
-                    Button("Cancel") {
1047
-                        dismiss()
1048
-                    }
1049
-                }
1050
-
1051
-                ToolbarItem(placement: .confirmationAction) {
1052
-                    Button("Save") {
1053
-                        saveCheckpoint()
1054
-                    }
1055
-                    .disabled(
1056
-                        (Double(batteryPercent) ?? -1) < 0
1057
-                            || (Double(batteryPercent) ?? 101) > 100
1058
-                    )
1059
-                }
1060
-            }
1061
-        }
1062
-        .navigationViewStyle(StackNavigationViewStyle())
1063
-        .alert(item: $confirmationWarning) { warning in
1064
-            Alert(
1065
-                title: Text(warning.title),
1066
-                message: Text(warning.message),
1067
-                primaryButton: .destructive(Text("Save Anyway")) {
1068
-                    saveCheckpoint(forceOverride: true)
1069
-                },
1070
-                secondaryButton: .cancel()
1071
-            )
1072
-        }
1073
-    }
1074
-
1075
-    private func saveCheckpoint(forceOverride: Bool = false) {
1076
-        guard let percent = Double(batteryPercent) else {
1077
-            return
1078
-        }
1079
-
1080
-        if !forceOverride, let plausibilityWarning {
1081
-            confirmationWarning = plausibilityWarning
1082
-            return
1083
-        }
1084
-
1085
-        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
1086
-            dismiss()
1087
-        }
1088
-    }
1089
-}
1090
-
1091 1063
 private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1092 1064
     @Environment(\.dismiss) private var dismiss
1093 1065
     @EnvironmentObject private var appData: AppData
+71 -39
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -36,7 +36,6 @@ struct MeterChargeRecordContentView: View {
36 36
 
37 37
     @State private var chargedDeviceLibraryVisibility = false
38 38
     @State private var chargerLibraryVisibility = false
39
-    @State private var checkpointEditorVisibility = false
40 39
     @State private var editingChargedDevice: ChargedDeviceSummary?
41 40
     @State private var targetNotificationEditorVisibility = false
42 41
     @State private var pendingStopRequest: ChargeSessionStopRequest?
@@ -46,6 +45,7 @@ struct MeterChargeRecordContentView: View {
46 45
     @State private var initialCheckpointMode: InitialCheckpointMode = .known
47 46
     @State private var initialCheckpoint = ""
48 47
     @State private var showsMeterTotalsInfo = false
48
+    @State private var showsInlineCheckpointEditor = false
49 49
 
50 50
     private enum SessionStartRequirement: Identifiable {
51 51
         case existingSession
@@ -143,11 +143,6 @@ struct MeterChargeRecordContentView: View {
143 143
             )
144 144
             .environmentObject(appData)
145 145
         }
146
-        .sheet(isPresented: $checkpointEditorVisibility) {
147
-            BatteryCheckpointEditorSheetView()
148
-                .environmentObject(appData)
149
-                .environmentObject(usbMeter)
150
-        }
151 146
         .sheet(item: $editingChargedDevice) { chargedDevice in
152 147
             ChargedDeviceEditorSheetView(
153 148
                 meterMACAddress: nil,
@@ -197,6 +192,7 @@ struct MeterChargeRecordContentView: View {
197 192
         }
198 193
         .onChange(of: openChargeSession?.id) { _ in
199 194
             syncDraftSelections()
195
+            showsInlineCheckpointEditor = false
200 196
         }
201 197
     }
202 198
 
@@ -730,6 +726,7 @@ struct MeterChargeRecordContentView: View {
730 726
 
731 727
     private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
732 728
         let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
729
+        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
733 730
         return VStack(alignment: .leading, spacing: 12) {
734 731
             HStack(spacing: 8) {
735 732
                 Text("Charging Monitor")
@@ -797,14 +794,31 @@ struct MeterChargeRecordContentView: View {
797 794
                     .foregroundColor(.secondary)
798 795
             }
799 796
 
800
-            Button("Add Battery Checkpoint") {
801
-                checkpointEditorVisibility = true
797
+            if !openChargeSession.checkpoints.isEmpty {
798
+                checkpointList(
799
+                    checkpoints: Array(openChargeSession.checkpoints.suffix(6).reversed())
800
+                )
801
+            }
802
+
803
+            Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
804
+                showsInlineCheckpointEditor.toggle()
802 805
             }
803 806
             .frame(maxWidth: .infinity)
804 807
             .padding(.vertical, 10)
805 808
             .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
806 809
             .buttonStyle(.plain)
807 810
 
811
+            if showsInlineCheckpointEditor {
812
+                BatteryCheckpointEditorContentView(
813
+                    sessionID: openChargeSession.id,
814
+                    message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
815
+                    effectiveEnergyWhOverride: displayedEnergyWh,
816
+                    measuredChargeAhOverride: displayedChargeAh,
817
+                    onCancel: { showsInlineCheckpointEditor = false },
818
+                    onSaved: { showsInlineCheckpointEditor = false }
819
+                )
820
+            }
821
+
808 822
             Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
809 823
                 targetNotificationEditorVisibility = true
810 824
             }
@@ -854,37 +868,6 @@ struct MeterChargeRecordContentView: View {
854 868
             .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
855 869
             .buttonStyle(.plain)
856 870
 
857
-            if !openChargeSession.checkpoints.isEmpty {
858
-                let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed())
859
-                VStack(alignment: .leading, spacing: 8) {
860
-                    Text("Battery Checkpoints")
861
-                        .font(.subheadline.weight(.semibold))
862
-
863
-                    ForEach(recentCheckpoints, id: \.id) { checkpoint in
864
-                        HStack {
865
-                            Text(checkpoint.timestamp.format())
866
-                                .font(.caption2)
867
-                                .foregroundColor(.secondary)
868
-                            Spacer()
869
-                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
870
-                                .font(.caption.weight(.semibold))
871
-                            Text("•")
872
-                                .foregroundColor(.secondary)
873
-                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
874
-                                .font(.caption2)
875
-                                .foregroundColor(.secondary)
876
-                            Button {
877
-                                pendingCheckpointDeletion = checkpoint
878
-                            } label: {
879
-                                Image(systemName: "trash")
880
-                                    .font(.caption.weight(.semibold))
881
-                                    .foregroundColor(.red)
882
-                            }
883
-                            .buttonStyle(.plain)
884
-                        }
885
-                    }
886
-                }
887
-            }
888 871
         }
889 872
         .padding(18)
890 873
         .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -1049,6 +1032,55 @@ struct MeterChargeRecordContentView: View {
1049 1032
         return storedEnergyWh
1050 1033
     }
1051 1034
 
1035
+    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1036
+        let storedChargeAh = session.measuredChargeAh
1037
+        guard session.status.isOpen else {
1038
+            return storedChargeAh
1039
+        }
1040
+
1041
+        guard session.meterMACAddress == meterMACAddress else {
1042
+            return storedChargeAh
1043
+        }
1044
+
1045
+        if let baselineChargeAh = session.meterChargeBaselineAh {
1046
+            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1047
+        }
1048
+
1049
+        return storedChargeAh
1050
+    }
1051
+
1052
+    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
1053
+        VStack(alignment: .leading, spacing: 8) {
1054
+            Text("Battery Checkpoints")
1055
+                .font(.subheadline.weight(.semibold))
1056
+
1057
+            ForEach(checkpoints, id: \.id) { checkpoint in
1058
+                HStack {
1059
+                    Text(checkpoint.timestamp.format())
1060
+                        .font(.caption2)
1061
+                        .foregroundColor(.secondary)
1062
+                    Spacer()
1063
+                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
1064
+                        .font(.caption.weight(.semibold))
1065
+                    Text("•")
1066
+                        .foregroundColor(.secondary)
1067
+                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
1068
+                        .font(.caption2)
1069
+                        .foregroundColor(.secondary)
1070
+                    Button {
1071
+                        pendingCheckpointDeletion = checkpoint
1072
+                    } label: {
1073
+                        Image(systemName: "trash")
1074
+                            .font(.caption.weight(.semibold))
1075
+                            .foregroundColor(.red)
1076
+                    }
1077
+                    .buttonStyle(.plain)
1078
+                    .help("Delete checkpoint")
1079
+                }
1080
+            }
1081
+        }
1082
+    }
1083
+
1052 1084
     private func formatDuration(_ duration: TimeInterval) -> String {
1053 1085
         let totalSeconds = Int(duration.rounded(.down))
1054 1086
         let hours = totalSeconds / 3600