Showing 14 changed files with 2551 additions and 2489 deletions
+4 -14
USB Meter.xcodeproj/project.pbxproj
@@ -63,8 +63,7 @@
63 63
 		C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
64 64
 		C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
65 65
 		C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */; };
66
-		C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */; };
67
-		C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */; };
66
+		C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */; };
68 67
 		C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
69 68
 		C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */ = {isa = PBXBuildFile; fileRef = C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */; };
70 69
 		CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */; };
@@ -95,7 +94,6 @@
95 94
 		D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
96 95
 		E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
97 96
 		F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
98
-		F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */; };
99 97
 /* End PBXBuildFile section */
100 98
 
101 99
 /* Begin PBXFileReference section */
@@ -191,12 +189,11 @@
191 189
 		C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 5.xcdatamodel"; sourceTree = "<group>"; };
192 190
 		C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionsView.swift; sourceTree = "<group>"; };
193 191
 		C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 6.xcdatamodel"; sourceTree = "<group>"; };
194
-		C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionDetailView.swift; sourceTree = "<group>"; };
192
+		C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionDetailView.swift; sourceTree = "<group>"; };
195 193
 		C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
196 194
 		C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
197 195
 		C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
198 196
 		C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
199
-		C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceActiveSessionView.swift; sourceTree = "<group>"; };
200 197
 		C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
201 198
 		C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
202 199
 		C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
@@ -229,7 +226,6 @@
229 226
 		E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
230 227
 		F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
231 228
 		F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
232
-		F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTrimEditorView.swift; sourceTree = "<group>"; };
233 229
 /* End PBXFileReference section */
234 230
 
235 231
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -486,11 +482,9 @@
486 482
 				AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */,
487 483
 				C10000203C8E4A7A00A10020 /* ChargedDevices */,
488 484
 				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
489
-				4327461A24619CED0009BE4B /* MeterRowView.swift */,
490 485
 				437D47CF2415F8CF00B7768E /* Meter */,
491 486
 				D28F10023C8E4A7A00A10002 /* Components */,
492 487
 				4311E639241384960080EA59 /* DeviceHelpView.swift */,
493
-				AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */,
494 488
 			);
495 489
 			path = Views;
496 490
 			sourceTree = "<group>";
@@ -556,8 +550,7 @@
556 550
 		CD0000123FA0000000000012 /* Sessions */ = {
557 551
 			isa = PBXGroup;
558 552
 			children = (
559
-				C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */,
560
-				C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */,
553
+				C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */,
561 554
 				C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */,
562 555
 			);
563 556
 			path = Sessions;
@@ -758,7 +751,6 @@
758 751
 			isa = PBXGroup;
759 752
 			children = (
760 753
 				D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */,
761
-				F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */,
762 754
 			);
763 755
 			path = ChargeRecord;
764 756
 			sourceTree = "<group>";
@@ -889,14 +881,12 @@
889 881
 				C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */,
890 882
 				C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */,
891 883
 				F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */,
892
-				F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */,
893 884
 				C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */,
894 885
 				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
895 886
 				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
896 887
 				C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */,
897
-				C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */,
898 888
 				C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */,
899
-				C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */,
889
+				C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */,
900 890
 				C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */,
901 891
 				C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */,
902 892
 				C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */,
+36 -2
USB Meter/Model/ChargeInsightsStore.swift
@@ -609,6 +609,8 @@ final class ChargeInsightsStore {
609 609
                 return
610 610
             }
611 611
 
612
+            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
613
+
612 614
             guard hasSavableChargeData(session) else {
613 615
                 return
614 616
             }
@@ -3054,11 +3056,43 @@ final class ChargeInsightsStore {
3054 3056
     }
3055 3057
 
3056 3058
     private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
3057
-        boolValue(session, key: "hasObservedChargeFlow")
3059
+        if boolValue(session, key: "hasObservedChargeFlow")
3058 3060
             || doubleValue(session, key: "measuredEnergyWh") > 0
3059 3061
             || doubleValue(session, key: "measuredChargeAh") > 0
3060 3062
             || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
3061
-            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0
3063
+            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3064
+            return true
3065
+        }
3066
+
3067
+        guard let sessionID = stringValue(session, key: "id") else {
3068
+            return false
3069
+        }
3070
+
3071
+        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3072
+            doubleValue(sample, key: "measuredEnergyWh") > 0
3073
+                || doubleValue(sample, key: "measuredChargeAh") > 0
3074
+                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3075
+                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3076
+        }
3077
+    }
3078
+
3079
+    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3080
+        guard let sessionID = stringValue(session, key: "id"),
3081
+              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3082
+                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3083
+              }) else {
3084
+            return
3085
+        }
3086
+
3087
+        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3088
+        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3089
+            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3090
+        }
3091
+
3092
+        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3093
+        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3094
+            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3095
+        }
3062 3096
     }
3063 3097
 
3064 3098
     private func derivedMinimumCurrent(
+4 -1
USB Meter/Views/ChargedDevices/Details/ChargedDeviceDetailView.swift
@@ -477,7 +477,10 @@ struct ChargedDeviceDetailView: View {
477 477
         chargedDevice: ChargedDeviceSummary
478 478
     ) -> some View {
479 479
         NavigationLink(
480
-            destination: ChargedDeviceActiveSessionView(chargedDeviceID: chargedDevice.id)
480
+            destination: ChargeSessionDetailView(
481
+                chargedDeviceID: chargedDevice.id,
482
+                sessionID: activeSession.id
483
+            )
481 484
         ) {
482 485
             VStack(alignment: .leading, spacing: 14) {
483 486
                 HStack(alignment: .firstTextBaseline) {
+1763 -0
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -0,0 +1,1763 @@
1
+//
2
+//  ChargeSessionDetailView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 22/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+enum ChargeSessionDetailPresentation {
11
+    case navigation
12
+    case embedded
13
+}
14
+
15
+struct ChargeSessionDetailView: View {
16
+    private enum FinalCheckpoint: Hashable {
17
+        case full
18
+        case skip
19
+        case custom
20
+
21
+        var label: String {
22
+            switch self {
23
+            case .full:   return "Full"
24
+            case .skip:   return "Skip"
25
+            case .custom: return "Other %"
26
+            }
27
+        }
28
+
29
+        var icon: String {
30
+            switch self {
31
+            case .full:   return "battery.100percent"
32
+            case .skip:   return "minus.circle"
33
+            case .custom: return "pencil"
34
+            }
35
+        }
36
+    }
37
+
38
+    @EnvironmentObject private var appData: AppData
39
+
40
+    let chargedDeviceID: UUID
41
+    let sessionID: UUID
42
+    let monitoringMeter: Meter?
43
+    let presentation: ChargeSessionDetailPresentation
44
+
45
+    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
46
+    @State private var pendingSessionDeletion: ChargeSessionSummary?
47
+    @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
48
+    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
49
+    @State private var trimBannerDismissedForSessionID: UUID?
50
+    @State private var showingInlineTargetEditor = false
51
+    @State private var draftTargetText = ""
52
+    @State private var showingStopConfirm = false
53
+    @State private var finalCheckpointMode: FinalCheckpoint = .skip
54
+    @State private var finalCheckpointText = ""
55
+    @State private var stopFailureMessage: String?
56
+
57
+    init(
58
+        chargedDeviceID: UUID,
59
+        sessionID: UUID,
60
+        monitoringMeter: Meter? = nil,
61
+        presentation: ChargeSessionDetailPresentation = .navigation
62
+    ) {
63
+        self.chargedDeviceID = chargedDeviceID
64
+        self.sessionID = sessionID
65
+        self.monitoringMeter = monitoringMeter
66
+        self.presentation = presentation
67
+    }
68
+
69
+    private var chargedDevice: ChargedDeviceSummary? {
70
+        appData.chargedDeviceSummary(id: chargedDeviceID)
71
+    }
72
+
73
+    private var session: ChargeSessionSummary? {
74
+        chargedDevice?.sessions.first(where: { $0.id == sessionID })
75
+    }
76
+
77
+    private var liveMonitoringMeter: Meter? {
78
+        guard let session,
79
+              session.status.isOpen,
80
+              let meterMACAddress = session.meterMACAddress else {
81
+            return nil
82
+        }
83
+
84
+        if let monitoringMeter,
85
+           monitoringMeter.btSerial.macAddress.description == meterMACAddress {
86
+            return monitoringMeter
87
+        }
88
+
89
+        return appData.meters.values.first {
90
+            $0.btSerial.macAddress.description == meterMACAddress
91
+        }
92
+    }
93
+
94
+    private var hasMonitoringControls: Bool {
95
+        session?.status.isOpen == true && liveMonitoringMeter != nil
96
+    }
97
+
98
+    private var shouldShowTrimBanner: Bool {
99
+        guard hasMonitoringControls,
100
+              let session,
101
+              session.isTrimmed == false,
102
+              trimBannerDismissedForSessionID != session.id,
103
+              let detectedTrimWindow else {
104
+            return false
105
+        }
106
+        return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold
107
+    }
108
+
109
+    var body: some View {
110
+        Group {
111
+            if let chargedDevice, let session {
112
+                content(chargedDevice: chargedDevice, session: session)
113
+            } else {
114
+                unavailableState
115
+            }
116
+        }
117
+        .sheet(item: $pendingSessionStopRequest) { request in
118
+            ChargeSessionCompletionSheetView(
119
+                sessionID: request.sessionID,
120
+                title: request.title,
121
+                confirmTitle: request.confirmTitle,
122
+                explanation: request.explanation,
123
+                monitoringMeter: liveMonitoringMeter,
124
+                appliesTrim: request.appliesTrim,
125
+                trimStart: request.trimStart,
126
+                trimEnd: request.trimEnd
127
+            )
128
+            .environmentObject(appData)
129
+        }
130
+        .alert(item: $pendingCheckpointDeletion) { checkpoint in
131
+            Alert(
132
+                title: Text("Delete Battery Checkpoint"),
133
+                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
134
+                primaryButton: .destructive(Text("Delete")) {
135
+                    _ = appData.deleteBatteryCheckpoint(
136
+                        checkpointID: checkpoint.id,
137
+                        for: checkpoint.sessionID
138
+                    )
139
+                },
140
+                secondaryButton: .cancel()
141
+            )
142
+        }
143
+        .alert(item: $pendingSessionDeletion) { session in
144
+            Alert(
145
+                title: Text("Delete Session?"),
146
+                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
147
+                primaryButton: .destructive(Text("Delete")) {
148
+                    _ = appData.deleteChargeSession(sessionID: session.id)
149
+                },
150
+                secondaryButton: .cancel()
151
+            )
152
+        }
153
+        .onAppear {
154
+            syncMonitoringRestore()
155
+            runTrimDetection()
156
+        }
157
+        .onChange(of: session?.id) { _ in
158
+            pendingSessionStopRequest = nil
159
+            detectedTrimWindow = nil
160
+            trimBannerDismissedForSessionID = nil
161
+            showingInlineTargetEditor = false
162
+            draftTargetText = ""
163
+            showingStopConfirm = false
164
+            finalCheckpointMode = .skip
165
+            finalCheckpointText = ""
166
+            stopFailureMessage = nil
167
+            syncMonitoringRestore()
168
+            runTrimDetection()
169
+        }
170
+        .onChange(of: session?.aggregatedSamples.count) { _ in
171
+            syncMonitoringRestore()
172
+            runTrimDetection()
173
+        }
174
+        .onChange(of: finalCheckpointMode) { _ in
175
+            stopFailureMessage = nil
176
+        }
177
+        .onChange(of: finalCheckpointText) { _ in
178
+            stopFailureMessage = nil
179
+        }
180
+    }
181
+
182
+    private func content(
183
+        chargedDevice: ChargedDeviceSummary,
184
+        session: ChargeSessionSummary
185
+    ) -> some View {
186
+        ScrollView {
187
+            VStack(spacing: 16) {
188
+                if hasMonitoringControls {
189
+                    monitoringSessionCard(session, chargedDevice: chargedDevice)
190
+
191
+                    if shouldShowTrimBanner {
192
+                        trimDetectionBanner(session)
193
+                    }
194
+
195
+                    if shouldShowSessionChart(session) {
196
+                        chartCard(session)
197
+                    }
198
+                } else {
199
+                    overviewCard(session, chargedDevice: chargedDevice)
200
+                    energyCard(session, chargedDevice: chargedDevice)
201
+                    observedMetricsCard(session, chargedDevice: chargedDevice)
202
+                    batteryCard(session, chargedDevice: chargedDevice)
203
+
204
+                    if shouldShowSessionChart(session) {
205
+                        chartCard(session)
206
+                    }
207
+
208
+                    if session.status.isOpen {
209
+                        followerNoticeCard(session)
210
+                    } else {
211
+                        managementCard(session)
212
+                    }
213
+                }
214
+            }
215
+            .padding(presentation == .embedded ? 16 : 20)
216
+        }
217
+        .background(
218
+            LinearGradient(
219
+                colors: [statusTint(for: session).opacity(0.14), Color.clear],
220
+                startPoint: .topLeading,
221
+                endPoint: .bottomTrailing
222
+            )
223
+            .ignoresSafeArea()
224
+        )
225
+        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
226
+    }
227
+
228
+    private var unavailableState: some View {
229
+        VStack(spacing: 12) {
230
+            Image(systemName: "bolt.slash")
231
+                .font(.title2)
232
+                .foregroundColor(.secondary)
233
+            Text("This session is no longer available.")
234
+                .font(.headline)
235
+            Text("It may have been deleted or synced from another device.")
236
+                .font(.footnote)
237
+                .foregroundColor(.secondary)
238
+                .multilineTextAlignment(.center)
239
+        }
240
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
241
+        .padding(24)
242
+        .navigationTitle("Session")
243
+    }
244
+
245
+    private func monitoringSessionCard(
246
+        _ session: ChargeSessionSummary,
247
+        chargedDevice: ChargedDeviceSummary
248
+    ) -> some View {
249
+        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
250
+        let displayedChargeAh = displayedSessionChargeAh(for: session)
251
+        let batteryPrediction = chargedDevice.batteryLevelPrediction(
252
+            for: session,
253
+            effectiveEnergyWhOverride: displayedEnergyWh
254
+        )
255
+
256
+        return VStack(alignment: .leading, spacing: 14) {
257
+            HStack {
258
+                ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
259
+                    .font(.headline)
260
+
261
+                Spacer()
262
+
263
+                Text(session.status.title)
264
+                    .font(.caption.weight(.bold))
265
+                    .foregroundColor(monitoringStatusColor(for: session))
266
+                    .padding(.horizontal, 8)
267
+                    .padding(.vertical, 4)
268
+                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
269
+            }
270
+
271
+            if let batteryPrediction {
272
+                batteryGaugeSection(
273
+                    prediction: batteryPrediction,
274
+                    session: session,
275
+                    displayedEnergyWh: displayedEnergyWh
276
+                )
277
+            }
278
+
279
+            sessionMetricsGrid(
280
+                session: session,
281
+                chargedDevice: chargedDevice,
282
+                displayedEnergyWh: displayedEnergyWh,
283
+                hasPrediction: batteryPrediction != nil
284
+            )
285
+
286
+            if session.stopThresholdAmps > 0 {
287
+                Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
288
+                    .font(.caption)
289
+                    .foregroundColor(.secondary)
290
+            }
291
+
292
+            if let sessionWarning = sessionWarning(for: session) {
293
+                Label(sessionWarning, systemImage: "exclamationmark.triangle")
294
+                    .font(.caption)
295
+                    .foregroundColor(.orange)
296
+            }
297
+
298
+            if session.isPaused {
299
+                Label(
300
+                    "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
301
+                    systemImage: "pause.circle"
302
+                )
303
+                .font(.caption)
304
+                .foregroundColor(.secondary)
305
+            }
306
+
307
+            if session.requiresCompletionConfirmation && !showingStopConfirm {
308
+                completionConfirmationCard(session)
309
+            }
310
+
311
+            BatteryCheckpointSectionView(
312
+                sessionID: session.id,
313
+                checkpoints: session.checkpoints,
314
+                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
315
+                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
316
+                canDeleteCheckpoint: true,
317
+                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
318
+                effectiveEnergyWhOverride: displayedEnergyWh,
319
+                measuredChargeAhOverride: displayedChargeAh,
320
+                onDelete: { checkpoint in
321
+                    pendingCheckpointDeletion = checkpoint
322
+                }
323
+            )
324
+
325
+            targetSectionView(
326
+                session: session,
327
+                predictedPercent: batteryPrediction?.predictedPercent
328
+            )
329
+
330
+            if showingStopConfirm {
331
+                stopConfirmPanel(
332
+                    session: session,
333
+                    displayedEnergyWh: displayedEnergyWh,
334
+                    displayedChargeAh: displayedChargeAh
335
+                )
336
+            } else {
337
+                monitoringActionRow(session)
338
+            }
339
+        }
340
+        .padding(18)
341
+        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
342
+    }
343
+
344
+    private func overviewCard(
345
+        _ session: ChargeSessionSummary,
346
+        chargedDevice: ChargedDeviceSummary
347
+    ) -> some View {
348
+        MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
349
+            MeterInfoRowView(label: "Device", value: chargedDevice.name)
350
+            MeterInfoRowView(label: "Status", value: session.status.title)
351
+            MeterInfoRowView(label: "Started", value: session.startedAt.format())
352
+            if let endedAt = session.endedAt {
353
+                MeterInfoRowView(label: "Ended", value: endedAt.format())
354
+            }
355
+            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
356
+            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
357
+            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
358
+            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
359
+            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
360
+            if session.isTrimmed {
361
+                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
362
+                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
363
+            }
364
+            if let meterName = session.meterName {
365
+                MeterInfoRowView(label: "Meter", value: meterName)
366
+            } else if let meterMACAddress = session.meterMACAddress {
367
+                MeterInfoRowView(label: "Meter", value: meterMACAddress)
368
+            }
369
+            if let meterModel = session.meterModel {
370
+                MeterInfoRowView(label: "Meter Model", value: meterModel)
371
+            }
372
+        }
373
+    }
374
+
375
+    private func energyCard(
376
+        _ session: ChargeSessionSummary,
377
+        chargedDevice: ChargedDeviceSummary
378
+    ) -> some View {
379
+        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
380
+        let displayedChargeAh = displayedSessionChargeAh(for: session)
381
+
382
+        return MeterInfoCardView(title: "Energy", tint: .teal) {
383
+            MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
384
+            if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
385
+                MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
386
+            }
387
+            MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah")
388
+            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
389
+               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
390
+                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
391
+            }
392
+            if let capacityEstimateWh = session.capacityEstimateWh {
393
+                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
394
+            }
395
+            if let chargerID = session.chargerID,
396
+               let charger = appData.chargedDeviceSummary(id: chargerID) {
397
+                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
398
+            }
399
+            if let wirelessSessionHint = wirelessSessionHint(for: session) {
400
+                Text(wirelessSessionHint)
401
+                    .font(.caption2)
402
+                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
403
+            }
404
+            if let sessionWarning = sessionWarning(for: session) {
405
+                Label(sessionWarning, systemImage: "exclamationmark.triangle")
406
+                    .font(.caption2)
407
+                    .foregroundColor(.orange)
408
+            }
409
+        }
410
+    }
411
+
412
+    private func observedMetricsCard(
413
+        _ session: ChargeSessionSummary,
414
+        chargedDevice: ChargedDeviceSummary
415
+    ) -> some View {
416
+        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
417
+            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
418
+                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
419
+            }
420
+            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
421
+                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
422
+            }
423
+            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
424
+                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
425
+            }
426
+            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
427
+                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
428
+            }
429
+            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
430
+                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
431
+            }
432
+            if let completionCurrentAmps = session.completionCurrentAmps {
433
+                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
434
+            }
435
+            if session.selectedDataGroup != nil {
436
+                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
437
+            }
438
+        }
439
+    }
440
+
441
+    private func batteryCard(
442
+        _ session: ChargeSessionSummary,
443
+        chargedDevice: ChargedDeviceSummary
444
+    ) -> some View {
445
+        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
446
+        let displayedChargeAh = displayedSessionChargeAh(for: session)
447
+        let batteryPrediction = chargedDevice.batteryLevelPrediction(
448
+            for: session,
449
+            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
450
+        )
451
+
452
+        return MeterInfoCardView(title: "Battery", tint: .orange) {
453
+            if let startBatteryPercent = session.startBatteryPercent {
454
+                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
455
+            }
456
+            if let endBatteryPercent = session.endBatteryPercent {
457
+                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
458
+            }
459
+            if let batteryDeltaPercent = session.batteryDeltaPercent {
460
+                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
461
+            }
462
+            if let targetBatteryPercent = session.targetBatteryPercent {
463
+                MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%")
464
+            }
465
+            if let batteryPrediction {
466
+                MeterInfoRowView(
467
+                    label: "Predicted Battery",
468
+                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
469
+                )
470
+                Text(
471
+                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
472
+                )
473
+                .font(.caption2)
474
+                .foregroundColor(.secondary)
475
+            }
476
+
477
+            BatteryCheckpointSectionView(
478
+                sessionID: session.id,
479
+                checkpoints: session.checkpoints,
480
+                message: session.status.isOpen
481
+                    ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
482
+                    : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
483
+                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
484
+                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
485
+                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
486
+                effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
487
+                measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil,
488
+                onDelete: { checkpoint in
489
+                    pendingCheckpointDeletion = checkpoint
490
+                }
491
+            )
492
+        }
493
+    }
494
+
495
+    private func batteryGaugeSection(
496
+        prediction: BatteryLevelPrediction,
497
+        session: ChargeSessionSummary,
498
+        displayedEnergyWh: Double
499
+    ) -> some View {
500
+        let percent = prediction.predictedPercent
501
+        let color = batteryColor(for: percent)
502
+        let duration = displayedSessionDuration(for: session)
503
+        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
504
+            ? displayedEnergyWh / duration
505
+            : nil
506
+        let etaToFull = etaText(
507
+            rateWhPerSec: rateWhPerSec,
508
+            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
509
+            isRelevant: percent < 98
510
+        )
511
+        let etaToTarget = etaToTargetText(
512
+            session: session,
513
+            prediction: prediction,
514
+            displayedEnergyWh: displayedEnergyWh,
515
+            rateWhPerSec: rateWhPerSec
516
+        )
517
+
518
+        return VStack(spacing: 10) {
519
+            HStack(alignment: .lastTextBaseline, spacing: 8) {
520
+                HStack(alignment: .lastTextBaseline, spacing: 3) {
521
+                    Text("\(Int(percent.rounded()))")
522
+                        .font(.system(size: 52, weight: .bold, design: .rounded))
523
+                        .foregroundColor(color)
524
+                        .monospacedDigit()
525
+                    Text("%")
526
+                        .font(.title2.weight(.semibold))
527
+                        .foregroundColor(color.opacity(0.8))
528
+                }
529
+
530
+                Spacer()
531
+
532
+                VStack(alignment: .trailing, spacing: 2) {
533
+                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
534
+                        .font(.callout.weight(.bold))
535
+                        .foregroundColor(.orange)
536
+                        .monospacedDigit()
537
+                    Text("est. capacity")
538
+                        .font(.caption2)
539
+                        .foregroundColor(.secondary)
540
+                }
541
+            }
542
+
543
+            batteryProgressBar(
544
+                percent: percent,
545
+                startPercent: session.startBatteryPercent,
546
+                targetPercent: session.targetBatteryPercent
547
+            )
548
+
549
+            HStack(spacing: 14) {
550
+                if let etaToFull {
551
+                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
552
+                }
553
+
554
+                if let etaToTarget, let target = session.targetBatteryPercent {
555
+                    etaPill(
556
+                        icon: "bell.badge.fill",
557
+                        tint: .indigo,
558
+                        value: etaToTarget,
559
+                        label: "to \(Int(target.rounded()))%"
560
+                    )
561
+                }
562
+
563
+                Spacer()
564
+
565
+                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
566
+                    .font(.caption2)
567
+                    .foregroundColor(.secondary)
568
+                    .multilineTextAlignment(.trailing)
569
+            }
570
+        }
571
+        .padding(14)
572
+        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
573
+    }
574
+
575
+    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
576
+        VStack(alignment: .leading, spacing: 1) {
577
+            HStack(spacing: 4) {
578
+                Image(systemName: icon)
579
+                    .font(.caption)
580
+                    .foregroundColor(tint)
581
+                Text(value)
582
+                    .font(.caption.weight(.bold))
583
+            }
584
+            Text(label)
585
+                .font(.caption2)
586
+                .foregroundColor(.secondary)
587
+        }
588
+    }
589
+
590
+    private func batteryProgressBar(
591
+        percent: Double,
592
+        startPercent: Double?,
593
+        targetPercent: Double?
594
+    ) -> some View {
595
+        let color = batteryColor(for: percent)
596
+        return GeometryReader { geo in
597
+            let width = geo.size.width
598
+            ZStack(alignment: .leading) {
599
+                Capsule()
600
+                    .fill(Color.primary.opacity(0.10))
601
+                Rectangle()
602
+                    .fill(
603
+                        LinearGradient(
604
+                            colors: [color.opacity(0.6), color],
605
+                            startPoint: .leading,
606
+                            endPoint: .trailing
607
+                        )
608
+                    )
609
+                    .frame(width: max(width * CGFloat(percent / 100), 4))
610
+                    .animation(.easeInOut(duration: 0.4), value: percent)
611
+                if let start = startPercent, start > 2, start < 98 {
612
+                    Rectangle()
613
+                        .fill(Color.white.opacity(0.55))
614
+                        .frame(width: 2, height: 20)
615
+                        .offset(x: width * CGFloat(start / 100) - 1)
616
+                }
617
+                if let target = targetPercent {
618
+                    Rectangle()
619
+                        .fill(Color.indigo.opacity(0.9))
620
+                        .frame(width: 2.5, height: 20)
621
+                        .offset(x: width * CGFloat(target / 100) - 1.25)
622
+                }
623
+            }
624
+            .clipShape(Capsule())
625
+        }
626
+        .frame(height: 20)
627
+    }
628
+
629
+    private func sessionMetricsGrid(
630
+        session: ChargeSessionSummary,
631
+        chargedDevice: ChargedDeviceSummary,
632
+        displayedEnergyWh: Double,
633
+        hasPrediction: Bool
634
+    ) -> some View {
635
+        let capacityFallback: Double? = hasPrediction ? nil : (
636
+            session.capacityEstimateWh
637
+                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
638
+                ?? chargedDevice.estimatedBatteryCapacityWh
639
+        )
640
+        let columns = [GridItem(.flexible()), GridItem(.flexible())]
641
+
642
+        return LazyVGrid(columns: columns, spacing: 8) {
643
+            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
644
+            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
645
+
646
+            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
647
+                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
648
+            }
649
+            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
650
+                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
651
+            }
652
+
653
+            metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
654
+
655
+            if let capacityFallback {
656
+                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
657
+            }
658
+        }
659
+    }
660
+
661
+    private func metricCell(label: String, value: String, tint: Color) -> some View {
662
+        VStack(alignment: .leading, spacing: 3) {
663
+            Text(label)
664
+                .font(.caption2)
665
+                .foregroundColor(.secondary)
666
+            Text(value)
667
+                .font(.subheadline.weight(.semibold))
668
+                .lineLimit(1)
669
+                .minimumScaleFactor(0.7)
670
+                .monospacedDigit()
671
+        }
672
+        .frame(maxWidth: .infinity, alignment: .leading)
673
+        .padding(.horizontal, 12)
674
+        .padding(.vertical, 10)
675
+        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
676
+    }
677
+
678
+    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
679
+        VStack(alignment: .leading, spacing: 10) {
680
+            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
681
+                .font(.subheadline.weight(.semibold))
682
+
683
+            if let contradictionPercent = session.completionContradictionPercent {
684
+                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
685
+                    .font(.caption)
686
+                    .foregroundColor(.secondary)
687
+            } else {
688
+                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
689
+                    .font(.caption)
690
+                    .foregroundColor(.secondary)
691
+            }
692
+
693
+            HStack(spacing: 10) {
694
+                Button("Finish") {
695
+                    beginStopConfirmation(for: session)
696
+                }
697
+                .frame(maxWidth: .infinity)
698
+                .padding(.vertical, 9)
699
+                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
700
+                .buttonStyle(.plain)
701
+
702
+                Button("Keep Monitoring") {
703
+                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
704
+                }
705
+                .frame(maxWidth: .infinity)
706
+                .padding(.vertical, 9)
707
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
708
+                .buttonStyle(.plain)
709
+            }
710
+        }
711
+        .padding(14)
712
+        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
713
+    }
714
+
715
+    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
716
+        let draftBelowPrediction: Bool = {
717
+            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
718
+            return draft <= predictedPercent
719
+        }()
720
+        let savedBelowPrediction: Bool = {
721
+            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
722
+            return saved <= predictedPercent
723
+        }()
724
+
725
+        return HStack(alignment: .center, spacing: 8) {
726
+            Image(systemName: "bell.badge")
727
+                .foregroundColor(.indigo)
728
+                .font(.subheadline)
729
+
730
+            Text("Notify at")
731
+                .font(.subheadline.weight(.semibold))
732
+
733
+            Spacer(minLength: 8)
734
+
735
+            if showingInlineTargetEditor {
736
+                targetEditorControls(
737
+                    session: session,
738
+                    draftBelowPrediction: draftBelowPrediction,
739
+                    predictedPercent: predictedPercent
740
+                )
741
+            } else {
742
+                savedTargetControls(
743
+                    session: session,
744
+                    savedBelowPrediction: savedBelowPrediction,
745
+                    predictedPercent: predictedPercent
746
+                )
747
+            }
748
+        }
749
+    }
750
+
751
+    private func targetEditorControls(
752
+        session: ChargeSessionSummary,
753
+        draftBelowPrediction: Bool,
754
+        predictedPercent: Double?
755
+    ) -> some View {
756
+        Group {
757
+            Button {
758
+                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
759
+                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
760
+            } label: {
761
+                Image(systemName: "minus.circle")
762
+                    .font(.title3)
763
+            }
764
+            .buttonStyle(.plain)
765
+
766
+            TextField("-", text: $draftTargetText)
767
+                .keyboardType(.decimalPad)
768
+                .textFieldStyle(.roundedBorder)
769
+                .frame(width: 48)
770
+                .multilineTextAlignment(.center)
771
+                .foregroundColor(draftBelowPrediction ? .orange : .primary)
772
+
773
+            Text("%")
774
+                .font(.subheadline)
775
+                .foregroundColor(.secondary)
776
+
777
+            if draftBelowPrediction, let predictedPercent {
778
+                predictionWarningButton(predictedPercent: predictedPercent)
779
+            }
780
+
781
+            Button {
782
+                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
783
+                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
784
+            } label: {
785
+                Image(systemName: "plus.circle")
786
+                    .font(.title3)
787
+            }
788
+            .buttonStyle(.plain)
789
+
790
+            Button {
791
+                if let value = parsedDraftTarget {
792
+                    _ = appData.setTargetBatteryPercent(value, for: session.id)
793
+                }
794
+                showingInlineTargetEditor = false
795
+            } label: {
796
+                Image(systemName: "checkmark.circle.fill")
797
+                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
798
+                    .font(.title3)
799
+            }
800
+            .buttonStyle(.plain)
801
+            .disabled(parsedDraftTarget == nil)
802
+
803
+            Button {
804
+                showingInlineTargetEditor = false
805
+                draftTargetText = ""
806
+            } label: {
807
+                Image(systemName: "xmark.circle")
808
+                    .foregroundColor(.secondary)
809
+                    .font(.title3)
810
+            }
811
+            .buttonStyle(.plain)
812
+        }
813
+    }
814
+
815
+    private func savedTargetControls(
816
+        session: ChargeSessionSummary,
817
+        savedBelowPrediction: Bool,
818
+        predictedPercent: Double?
819
+    ) -> some View {
820
+        Group {
821
+            if let targetPercent = session.targetBatteryPercent {
822
+                Text("\(targetPercent.format(decimalDigits: 0))%")
823
+                    .font(.subheadline.weight(.semibold))
824
+                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
825
+
826
+                if savedBelowPrediction, let predictedPercent {
827
+                    predictionWarningButton(predictedPercent: predictedPercent)
828
+                }
829
+
830
+                Button {
831
+                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
832
+                } label: {
833
+                    Image(systemName: "xmark.circle.fill")
834
+                        .foregroundColor(.secondary)
835
+                        .font(.callout)
836
+                }
837
+                .buttonStyle(.plain)
838
+                .help("Remove alert")
839
+            }
840
+
841
+            Button {
842
+                draftTargetText = session.targetBatteryPercent.map {
843
+                    $0.format(decimalDigits: 0)
844
+                } ?? "80"
845
+                showingInlineTargetEditor = true
846
+            } label: {
847
+                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
848
+                    .font(.caption.weight(.semibold))
849
+                    .frame(width: 30, height: 30)
850
+                    .contentShape(Rectangle())
851
+            }
852
+            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
853
+            .buttonStyle(.plain)
854
+            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
855
+        }
856
+    }
857
+
858
+    private func predictionWarningButton(predictedPercent: Double) -> some View {
859
+        Button {} label: {
860
+            Image(systemName: "exclamationmark.triangle.fill")
861
+                .font(.callout.weight(.semibold))
862
+                .foregroundColor(.orange)
863
+        }
864
+        .buttonStyle(.plain)
865
+        .help("Battery is already predicted at \(predictedPercent.format(decimalDigits: 0))% - this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
866
+    }
867
+
868
+    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
869
+        HStack(spacing: 10) {
870
+            if session.status == .active {
871
+                Button("Pause") {
872
+                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
873
+                }
874
+                .monitoringActionStyle(tint: .orange)
875
+            } else if session.status == .paused {
876
+                Button("Resume") {
877
+                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
878
+                }
879
+                .monitoringActionStyle(tint: .blue)
880
+            }
881
+
882
+            Button("Terminate Session") {
883
+                beginStopConfirmation(for: session)
884
+            }
885
+            .monitoringActionStyle(tint: .red)
886
+        }
887
+    }
888
+
889
+    private func stopConfirmPanel(
890
+        session: ChargeSessionSummary,
891
+        displayedEnergyWh: Double,
892
+        displayedChargeAh: Double
893
+    ) -> some View {
894
+        let canSave = hasSavableChargeData(
895
+            session: session,
896
+            displayedEnergyWh: displayedEnergyWh,
897
+            displayedChargeAh: displayedChargeAh
898
+        )
899
+        let saveDisabledReason = saveDisabledReason(
900
+            session: session,
901
+            displayedEnergyWh: displayedEnergyWh,
902
+            displayedChargeAh: displayedChargeAh
903
+        )
904
+        let isSaveEnabled = saveDisabledReason == nil
905
+
906
+        return VStack(alignment: .leading, spacing: 12) {
907
+            HStack {
908
+                Text("Final Checkpoint")
909
+                    .font(.subheadline.weight(.semibold))
910
+                Text("optional")
911
+                    .font(.caption2.weight(.semibold))
912
+                    .foregroundColor(.secondary)
913
+            }
914
+
915
+            finalCheckpointPicker(session)
916
+
917
+            if finalCheckpointMode == .custom {
918
+                customFinalCheckpointRow
919
+            }
920
+
921
+            if let saveDisabledReason {
922
+                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
923
+                    .font(.caption)
924
+                    .foregroundColor(.red)
925
+                    .fixedSize(horizontal: false, vertical: true)
926
+            } else if let stopFailureMessage {
927
+                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
928
+                    .font(.caption)
929
+                    .foregroundColor(.red)
930
+                    .fixedSize(horizontal: false, vertical: true)
931
+            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
932
+                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
933
+                    .font(.caption)
934
+                    .foregroundColor(.green)
935
+                    .fixedSize(horizontal: false, vertical: true)
936
+            }
937
+
938
+            HStack(spacing: 8) {
939
+                Button("Discard") {
940
+                    discardSession(session)
941
+                }
942
+                .monitoringPanelActionStyle(tint: .secondary)
943
+
944
+                Button {
945
+                    stopSession(
946
+                        session,
947
+                        displayedEnergyWh: displayedEnergyWh,
948
+                        displayedChargeAh: displayedChargeAh
949
+                    )
950
+                } label: {
951
+                    Label("Save Session", systemImage: "checkmark.circle.fill")
952
+                        .frame(maxWidth: .infinity)
953
+                }
954
+                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
955
+                .disabled(!isSaveEnabled)
956
+                .help(saveDisabledReason ?? "Close and save this session")
957
+
958
+                Button("Cancel") {
959
+                    resetStopConfirmation()
960
+                }
961
+                .monitoringPanelActionStyle(tint: .secondary)
962
+            }
963
+        }
964
+        .padding(14)
965
+        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
966
+    }
967
+
968
+    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
969
+        return HStack(spacing: 8) {
970
+            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
971
+                Button {
972
+                    finalCheckpointMode = mode
973
+                    if mode == .custom {
974
+                        prefillFinalCheckpointIfNeeded(for: session)
975
+                    } else {
976
+                        finalCheckpointText = ""
977
+                    }
978
+                } label: {
979
+                    VStack(spacing: 5) {
980
+                        Image(systemName: mode.icon)
981
+                            .font(.title3)
982
+                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
983
+                        Text(mode.label)
984
+                            .font(.caption.weight(.semibold))
985
+                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
986
+                    }
987
+                    .frame(maxWidth: .infinity)
988
+                    .padding(.vertical, 10)
989
+                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
990
+                    .meterCard(
991
+                        tint: finalCheckpointMode == mode ? .primary : .secondary,
992
+                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
993
+                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
994
+                        cornerRadius: 12
995
+                    )
996
+                }
997
+                .buttonStyle(.plain)
998
+            }
999
+        }
1000
+    }
1001
+
1002
+    private var customFinalCheckpointRow: some View {
1003
+        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1004
+            || parsedFinalCheckpoint == nil
1005
+
1006
+        return HStack(spacing: 8) {
1007
+            Button {
1008
+                adjustFinalCheckpoint(by: -1)
1009
+            } label: {
1010
+                Image(systemName: "minus.circle").font(.title3)
1011
+            }
1012
+            .buttonStyle(.plain)
1013
+
1014
+            TextField("-", text: $finalCheckpointText)
1015
+                .keyboardType(.decimalPad)
1016
+                .textFieldStyle(.roundedBorder)
1017
+                .frame(width: 56)
1018
+                .multilineTextAlignment(.center)
1019
+                .overlay(
1020
+                    RoundedRectangle(cornerRadius: 6)
1021
+                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1022
+                )
1023
+
1024
+            Text("%").foregroundColor(.secondary)
1025
+
1026
+            Text("required")
1027
+                .font(.caption2.weight(.semibold))
1028
+                .foregroundColor(isInvalid ? .red : .secondary)
1029
+
1030
+            Button {
1031
+                adjustFinalCheckpoint(by: 1)
1032
+            } label: {
1033
+                Image(systemName: "plus.circle").font(.title3)
1034
+            }
1035
+            .buttonStyle(.plain)
1036
+
1037
+            Spacer()
1038
+        }
1039
+    }
1040
+
1041
+    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1042
+        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1043
+            if let meterName = session.meterName {
1044
+                MeterInfoRowView(label: "Controlled On", value: meterName)
1045
+            }
1046
+            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1047
+                .font(.caption2)
1048
+                .foregroundColor(.secondary)
1049
+        }
1050
+    }
1051
+
1052
+    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1053
+        MeterInfoCardView(title: "Administration", tint: .red) {
1054
+            Button(role: .destructive) {
1055
+                pendingSessionDeletion = session
1056
+            } label: {
1057
+                Label("Delete Session", systemImage: "trash")
1058
+                    .font(.subheadline.weight(.semibold))
1059
+                    .frame(maxWidth: .infinity)
1060
+                    .padding(.vertical, 10)
1061
+                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1062
+            }
1063
+            .buttonStyle(.plain)
1064
+        }
1065
+    }
1066
+
1067
+    @ViewBuilder
1068
+    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1069
+        if let window = detectedTrimWindow {
1070
+            HStack(spacing: 12) {
1071
+                Image(systemName: "scissors.circle.fill")
1072
+                    .font(.title3)
1073
+                    .foregroundColor(.blue)
1074
+
1075
+                VStack(alignment: .leading, spacing: 2) {
1076
+                    Text("Charging ended early")
1077
+                        .font(.subheadline.weight(.semibold))
1078
+                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1079
+                        .font(.caption)
1080
+                        .foregroundColor(.secondary)
1081
+                        .fixedSize(horizontal: false, vertical: true)
1082
+                }
1083
+
1084
+                Spacer(minLength: 0)
1085
+
1086
+                VStack(spacing: 6) {
1087
+                    Button("Trim Start") {
1088
+                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1089
+                        trimBannerDismissedForSessionID = session.id
1090
+                    }
1091
+                    .font(.caption.weight(.semibold))
1092
+                    .buttonStyle(.borderedProminent)
1093
+                    .controlSize(.small)
1094
+                    .tint(.blue)
1095
+
1096
+                    Button("End & Finish") {
1097
+                        requestStop(
1098
+                            session,
1099
+                            applyingTrimStart: session.trimStart ?? window.start,
1100
+                            trimEnd: window.end,
1101
+                            title: "Trim End & Finish",
1102
+                            confirmTitle: "Finish",
1103
+                            explanation: "The detected charging window will be saved before the session is closed."
1104
+                        )
1105
+                        trimBannerDismissedForSessionID = session.id
1106
+                    }
1107
+                    .font(.caption.weight(.semibold))
1108
+                    .buttonStyle(.bordered)
1109
+                    .controlSize(.small)
1110
+                    .tint(.red)
1111
+                }
1112
+            }
1113
+            .padding(14)
1114
+            .background(
1115
+                RoundedRectangle(cornerRadius: 14)
1116
+                    .fill(Color.blue.opacity(0.10))
1117
+                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1118
+            )
1119
+            .transition(.opacity.combined(with: .move(edge: .top)))
1120
+        }
1121
+    }
1122
+
1123
+    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1124
+        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1125
+    }
1126
+
1127
+    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1128
+        ChargeSessionChartCardView(
1129
+            session: session,
1130
+            monitoringMeter: liveMonitoringMeter,
1131
+            controlMode: chartControlMode(for: session),
1132
+            onSetTrim: { start, end in
1133
+                setSessionTrim(sessionID: session.id, start: start, end: end)
1134
+            },
1135
+            onStopWithTrim: { start, end in
1136
+                requestStop(
1137
+                    session,
1138
+                    applyingTrimStart: start,
1139
+                    trimEnd: end,
1140
+                    title: "Trim End & Finish",
1141
+                    confirmTitle: "Finish",
1142
+                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1143
+                )
1144
+            }
1145
+        )
1146
+    }
1147
+
1148
+    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1149
+        if hasMonitoringControls {
1150
+            return .activeMonitoring
1151
+        }
1152
+
1153
+        if session.status.isOpen == false {
1154
+            return .closed
1155
+        }
1156
+
1157
+        return .none
1158
+    }
1159
+
1160
+    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1161
+        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1162
+        trimBannerDismissedForSessionID = sessionID
1163
+    }
1164
+
1165
+    private func requestStop(
1166
+        _ session: ChargeSessionSummary,
1167
+        applyingTrimStart trimStart: Date?,
1168
+        trimEnd: Date?,
1169
+        title: String,
1170
+        confirmTitle: String,
1171
+        explanation: String
1172
+    ) {
1173
+        pendingSessionStopRequest = ChargeSessionStopRequest(
1174
+            sessionID: session.id,
1175
+            title: title,
1176
+            confirmTitle: confirmTitle,
1177
+            explanation: explanation,
1178
+            appliesTrim: trimStart != nil || trimEnd != nil,
1179
+            trimStart: trimStart,
1180
+            trimEnd: trimEnd
1181
+        )
1182
+    }
1183
+
1184
+    private var parsedDraftTarget: Double? {
1185
+        let normalized = draftTargetText
1186
+            .trimmingCharacters(in: .whitespacesAndNewlines)
1187
+            .replacingOccurrences(of: ",", with: ".")
1188
+        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1189
+        return value
1190
+    }
1191
+
1192
+    private var parsedFinalCheckpoint: Double? {
1193
+        let normalized = finalCheckpointText
1194
+            .trimmingCharacters(in: .whitespacesAndNewlines)
1195
+            .replacingOccurrences(of: ",", with: ".")
1196
+        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1197
+        return value
1198
+    }
1199
+
1200
+    private var resolvedFinalCheckpoint: Double? {
1201
+        switch finalCheckpointMode {
1202
+        case .full:   return 100
1203
+        case .skip:   return nil
1204
+        case .custom: return parsedFinalCheckpoint
1205
+        }
1206
+    }
1207
+
1208
+    private func adjustFinalCheckpoint(by delta: Double) {
1209
+        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1210
+        let next = min(max(current + delta, 0), 100)
1211
+        finalCheckpointText = next.format(decimalDigits: 0)
1212
+    }
1213
+
1214
+    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1215
+        guard let session else { return nil }
1216
+        if let endBatteryPercent = session.endBatteryPercent {
1217
+            return endBatteryPercent
1218
+        }
1219
+        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1220
+            return latestCheckpoint.batteryPercent
1221
+        }
1222
+        return session.targetBatteryPercent ?? session.completionContradictionPercent
1223
+    }
1224
+
1225
+    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1226
+        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1227
+              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1228
+            return
1229
+        }
1230
+        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1231
+    }
1232
+
1233
+    private func hasSavableChargeData(
1234
+        session: ChargeSessionSummary,
1235
+        displayedEnergyWh: Double,
1236
+        displayedChargeAh: Double
1237
+    ) -> Bool {
1238
+        session.hasSavableChargeData
1239
+            || displayedEnergyWh > 0
1240
+            || displayedChargeAh > 0
1241
+    }
1242
+
1243
+    private func saveDisabledReason(
1244
+        session: ChargeSessionSummary,
1245
+        displayedEnergyWh: Double,
1246
+        displayedChargeAh: Double
1247
+    ) -> String? {
1248
+        if finalCheckpointMode == .custom {
1249
+            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1250
+            if trimmed.isEmpty {
1251
+                return "Enter the final battery percentage or choose Skip."
1252
+            }
1253
+            if parsedFinalCheckpoint == nil {
1254
+                return "Final battery percentage must be between 0 and 100."
1255
+            }
1256
+        }
1257
+
1258
+        guard hasSavableChargeData(
1259
+            session: session,
1260
+            displayedEnergyWh: displayedEnergyWh,
1261
+            displayedChargeAh: displayedChargeAh
1262
+        ) else {
1263
+            return "This session has no charging data to save. Discard it instead."
1264
+        }
1265
+
1266
+        return nil
1267
+    }
1268
+
1269
+    private func stopSession(
1270
+        _ session: ChargeSessionSummary,
1271
+        displayedEnergyWh: Double,
1272
+        displayedChargeAh: Double
1273
+    ) {
1274
+        stopFailureMessage = nil
1275
+
1276
+        if let saveDisabledReason = saveDisabledReason(
1277
+            session: session,
1278
+            displayedEnergyWh: displayedEnergyWh,
1279
+            displayedChargeAh: displayedChargeAh
1280
+        ) {
1281
+            stopFailureMessage = saveDisabledReason
1282
+            return
1283
+        }
1284
+
1285
+        let didSave = appData.stopChargeSession(
1286
+            sessionID: session.id,
1287
+            finalBatteryPercent: resolvedFinalCheckpoint,
1288
+            from: liveMonitoringMeter
1289
+        )
1290
+        if didSave {
1291
+            resetStopConfirmation()
1292
+        } else {
1293
+            stopFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment."
1294
+        }
1295
+    }
1296
+
1297
+    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1298
+        finalCheckpointMode = .skip
1299
+        finalCheckpointText = ""
1300
+        stopFailureMessage = nil
1301
+        showingStopConfirm = true
1302
+    }
1303
+
1304
+    private func discardSession(_ session: ChargeSessionSummary) {
1305
+        _ = appData.deleteChargeSession(sessionID: session.id)
1306
+        resetStopConfirmation()
1307
+    }
1308
+
1309
+    private func resetStopConfirmation() {
1310
+        showingStopConfirm = false
1311
+        finalCheckpointText = ""
1312
+        finalCheckpointMode = .skip
1313
+        stopFailureMessage = nil
1314
+    }
1315
+
1316
+    private func syncMonitoringRestore() {
1317
+        guard let session,
1318
+              session.status.isOpen,
1319
+              let liveMonitoringMeter,
1320
+              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1321
+            return
1322
+        }
1323
+        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1324
+    }
1325
+
1326
+    private func runTrimDetection() {
1327
+        guard hasMonitoringControls,
1328
+              let session,
1329
+              session.isTrimmed == false,
1330
+              !session.aggregatedSamples.isEmpty else {
1331
+            detectedTrimWindow = nil
1332
+            return
1333
+        }
1334
+
1335
+        let sessionEnd = session.endedAt ?? session.lastObservedAt
1336
+        detectedTrimWindow = ChargingWindowDetector.detect(
1337
+            samples: session.aggregatedSamples,
1338
+            sessionStart: session.startedAt,
1339
+            sessionEnd: sessionEnd
1340
+        )
1341
+    }
1342
+
1343
+    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1344
+        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1345
+        guard session.isTrimmed == false else { return storedEnergyWh }
1346
+        guard session.status.isOpen else { return storedEnergyWh }
1347
+        guard let liveMonitoringMeter else { return storedEnergyWh }
1348
+        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1349
+        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1350
+            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1351
+        }
1352
+        return storedEnergyWh
1353
+    }
1354
+
1355
+    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1356
+        let storedChargeAh = session.measuredChargeAh
1357
+        guard session.isTrimmed == false else { return storedChargeAh }
1358
+        guard session.status.isOpen else { return storedChargeAh }
1359
+        guard let liveMonitoringMeter else { return storedChargeAh }
1360
+        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
1361
+        if let baselineChargeAh = session.meterChargeBaselineAh {
1362
+            return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0))
1363
+        }
1364
+        return storedChargeAh
1365
+    }
1366
+
1367
+    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1368
+        let storedDuration = max(session.effectiveDuration, 0)
1369
+        guard session.isTrimmed == false else { return storedDuration }
1370
+        guard session.status.isOpen else { return storedDuration }
1371
+        guard let liveMonitoringMeter else { return storedDuration }
1372
+        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1373
+        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1374
+    }
1375
+
1376
+    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1377
+        let displayedDuration = displayedSessionDuration(for: session)
1378
+        let formatter = DateComponentsFormatter()
1379
+        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1380
+        formatter.unitsStyle = .abbreviated
1381
+        formatter.zeroFormattingBehavior = .dropAll
1382
+        return formatter.string(from: displayedDuration) ?? "0m"
1383
+    }
1384
+
1385
+    private func formatDuration(_ duration: TimeInterval) -> String {
1386
+        let totalSeconds = Int(duration.rounded(.down))
1387
+        let hours = totalSeconds / 3600
1388
+        let minutes = (totalSeconds % 3600) / 60
1389
+        let seconds = totalSeconds % 60
1390
+        if hours > 0 {
1391
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1392
+        }
1393
+        return String(format: "%02d:%02d", minutes, seconds)
1394
+    }
1395
+
1396
+    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1397
+        if session.autoStopEnabled == false {
1398
+            return "Manual"
1399
+        }
1400
+
1401
+        if let sessionWarning = sessionWarning(for: session),
1402
+           sessionWarning.contains("idle-current") {
1403
+            return "Blocked by charger setup"
1404
+        }
1405
+
1406
+        if session.stopThresholdAmps > 0 {
1407
+            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1408
+        }
1409
+
1410
+        return "Learning"
1411
+    }
1412
+
1413
+    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1414
+        if session.autoStopEnabled == false {
1415
+            return "Manual"
1416
+        }
1417
+        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1418
+            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1419
+        }
1420
+        if session.stopThresholdAmps > 0 {
1421
+            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1422
+        }
1423
+        return "Learning"
1424
+    }
1425
+
1426
+    private func shouldShowChargingTransport(
1427
+        for session: ChargeSessionSummary,
1428
+        chargedDevice: ChargedDeviceSummary
1429
+    ) -> Bool {
1430
+        chargedDevice.supportedChargingModes.count > 1
1431
+            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1432
+    }
1433
+
1434
+    private func shouldShowChargingState(
1435
+        for session: ChargeSessionSummary,
1436
+        chargedDevice: ChargedDeviceSummary
1437
+    ) -> Bool {
1438
+        chargedDevice.supportedChargingStateModes.count > 1
1439
+            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1440
+    }
1441
+
1442
+    private func batteryColor(for percent: Double) -> Color {
1443
+        if percent >= 75 { return .green }
1444
+        if percent >= 35 { return .orange }
1445
+        return .red
1446
+    }
1447
+
1448
+    private func etaText(
1449
+        rateWhPerSec: Double?,
1450
+        remainingWh: Double,
1451
+        isRelevant: Bool
1452
+    ) -> String? {
1453
+        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1454
+        let seconds = remainingWh / rateWhPerSec
1455
+        return seconds > 120 ? formatETA(seconds) : nil
1456
+    }
1457
+
1458
+    private func etaToTargetText(
1459
+        session: ChargeSessionSummary,
1460
+        prediction: BatteryLevelPrediction,
1461
+        displayedEnergyWh: Double,
1462
+        rateWhPerSec: Double?
1463
+    ) -> String? {
1464
+        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1465
+            return nil
1466
+        }
1467
+        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1468
+        return etaText(
1469
+            rateWhPerSec: rateWhPerSec,
1470
+            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1471
+            isRelevant: true
1472
+        )
1473
+    }
1474
+
1475
+    private func formatETA(_ seconds: TimeInterval) -> String {
1476
+        let totalMinutes = Int(seconds / 60)
1477
+        if totalMinutes < 60 { return "\(totalMinutes)m" }
1478
+        let hours = totalMinutes / 60
1479
+        let minutes = totalMinutes % 60
1480
+        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1481
+    }
1482
+
1483
+    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1484
+        switch session.status {
1485
+        case .active:
1486
+            return .red
1487
+        case .paused:
1488
+            return .orange
1489
+        case .completed:
1490
+            return .green
1491
+        case .abandoned:
1492
+            return .secondary
1493
+        }
1494
+    }
1495
+
1496
+    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1497
+        guard session.chargingTransportMode == .wireless,
1498
+              let chargerID = session.chargerID,
1499
+              let charger = appData.chargedDeviceSummary(id: chargerID),
1500
+              charger.chargerIdleCurrentAmps == nil else {
1501
+            return nil
1502
+        }
1503
+
1504
+        return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session."
1505
+    }
1506
+
1507
+    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1508
+        guard session.chargingTransportMode == .wireless else {
1509
+            return nil
1510
+        }
1511
+
1512
+        var components: [String] = []
1513
+        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1514
+            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1515
+        }
1516
+        if session.usesEstimatedWirelessEfficiency {
1517
+            components.append("Estimated from wired baseline and checkpoints")
1518
+        }
1519
+        if session.shouldWarnAboutLowWirelessEfficiency {
1520
+            components.append("Low wireless efficiency, so capacity confidence is reduced")
1521
+        }
1522
+
1523
+        return components.isEmpty ? nil : components.joined(separator: " - ")
1524
+    }
1525
+
1526
+    private func statusTint(for session: ChargeSessionSummary) -> Color {
1527
+        switch session.status {
1528
+        case .active:
1529
+            return .green
1530
+        case .paused:
1531
+            return .orange
1532
+        case .completed:
1533
+            return .teal
1534
+        case .abandoned:
1535
+            return .secondary
1536
+        }
1537
+    }
1538
+}
1539
+
1540
+enum ChargeSessionChartControlMode {
1541
+    case none
1542
+    case activeMonitoring
1543
+    case closed
1544
+}
1545
+
1546
+struct ChargeSessionChartCardView: View {
1547
+    let session: ChargeSessionSummary
1548
+    let monitoringMeter: Meter?
1549
+    let controlMode: ChargeSessionChartControlMode
1550
+    let onSetTrim: (Date?, Date?) -> Void
1551
+    let onStopWithTrim: (Date?, Date?) -> Void
1552
+
1553
+    @StateObject private var storedMeasurements = Measurements()
1554
+
1555
+    private var chartMeasurements: Measurements {
1556
+        if let monitoringMeter,
1557
+           session.status.isOpen,
1558
+           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1559
+            return monitoringMeter.chargeRecordMeasurements
1560
+        }
1561
+        return storedMeasurements
1562
+    }
1563
+
1564
+    private var fullTimeRange: ClosedRange<Date> {
1565
+        let start = session.startedAt
1566
+        let end = max(session.endedAt ?? session.lastObservedAt, start)
1567
+        return start...end
1568
+    }
1569
+
1570
+    private var fixedTimeRange: ClosedRange<Date>? {
1571
+        if monitoringMeter != nil && session.status.isOpen {
1572
+            return nil
1573
+        }
1574
+        return session.effectiveTimeRange
1575
+    }
1576
+
1577
+    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1578
+        guard monitoringMeter != nil && session.status.isOpen else {
1579
+            return (nil, nil)
1580
+        }
1581
+        return (session.trimStart, session.trimEnd)
1582
+    }
1583
+
1584
+    private var showsRangeSelector: Bool {
1585
+        controlMode != .none && !session.aggregatedSamples.isEmpty
1586
+    }
1587
+
1588
+    var body: some View {
1589
+        VStack(alignment: .leading, spacing: 12) {
1590
+            HStack(spacing: 8) {
1591
+                Image(systemName: "chart.xyaxis.line")
1592
+                    .foregroundColor(.blue)
1593
+                Text("Session Chart")
1594
+                    .font(.headline)
1595
+                ContextInfoButton(
1596
+                    title: "Session Chart",
1597
+                    message: chartInfoMessage
1598
+                )
1599
+                Spacer(minLength: 0)
1600
+            }
1601
+
1602
+            MeasurementChartView(
1603
+                timeRange: fixedTimeRange,
1604
+                timeRangeLowerBound: liveTrimBounds.lower,
1605
+                timeRangeUpperBound: liveTrimBounds.upper,
1606
+                showsRangeSelector: showsRangeSelector,
1607
+                rebasesEnergyToVisibleRangeStart: true,
1608
+                extendsTimelineToPresent: false,
1609
+                showsTemperatureSeries: false,
1610
+                rangeSelectorConfiguration: rangeSelectorConfiguration
1611
+            )
1612
+            .environmentObject(chartMeasurements)
1613
+            .frame(maxWidth: .infinity, alignment: .topLeading)
1614
+        }
1615
+        .padding(18)
1616
+        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1617
+        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1618
+        .onChange(of: session.id) { _ in
1619
+            restoreStoredMeasurementsIfNeeded()
1620
+        }
1621
+        .onChange(of: session.aggregatedSamples.count) { _ in
1622
+            restoreStoredMeasurementsIfNeeded()
1623
+        }
1624
+    }
1625
+
1626
+    private var chartInfoMessage: String {
1627
+        if monitoringMeter != nil && session.status.isOpen {
1628
+            return "This chart combines the persisted session curve with current live data from this meter."
1629
+        }
1630
+
1631
+        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1632
+    }
1633
+
1634
+    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1635
+        switch controlMode {
1636
+        case .none:
1637
+            return nil
1638
+        case .activeMonitoring:
1639
+            return MeasurementChartRangeSelectorConfiguration(
1640
+                keepAction: MeasurementChartSelectionAction(
1641
+                    title: "Trim Start",
1642
+                    shortTitle: "Start",
1643
+                    systemName: "arrow.right.to.line",
1644
+                    tone: .destructive,
1645
+                    handler: applyActiveStartTrim
1646
+                ),
1647
+                removeAction: MeasurementChartSelectionAction(
1648
+                    title: "Trim End & Finish",
1649
+                    shortTitle: "End",
1650
+                    systemName: "arrow.left.to.line",
1651
+                    tone: .destructiveProminent,
1652
+                    handler: requestActiveEndTrim
1653
+                ),
1654
+                resetAction: MeasurementChartResetAction(
1655
+                    title: "Reset Trim",
1656
+                    shortTitle: "Reset",
1657
+                    systemName: "arrow.counterclockwise",
1658
+                    tone: .reversible,
1659
+                    confirmationTitle: "Reset session trim?",
1660
+                    confirmationButtonTitle: "Reset trim",
1661
+                    handler: {
1662
+                        onSetTrim(nil, nil)
1663
+                    }
1664
+                )
1665
+            )
1666
+        case .closed:
1667
+            return MeasurementChartRangeSelectorConfiguration(
1668
+                keepAction: MeasurementChartSelectionAction(
1669
+                    title: "Trim Window",
1670
+                    shortTitle: "Trim",
1671
+                    systemName: "scissors",
1672
+                    tone: .destructive,
1673
+                    handler: applyClosedTrim
1674
+                ),
1675
+                removeAction: nil,
1676
+                resetAction: MeasurementChartResetAction(
1677
+                    title: "Reset Trim",
1678
+                    shortTitle: "Reset",
1679
+                    systemName: "arrow.counterclockwise",
1680
+                    tone: .reversible,
1681
+                    confirmationTitle: "Reset session trim?",
1682
+                    confirmationButtonTitle: "Reset trim",
1683
+                    handler: {
1684
+                        onSetTrim(nil, nil)
1685
+                    }
1686
+                )
1687
+            )
1688
+        }
1689
+    }
1690
+
1691
+    private func restoreStoredMeasurementsIfNeeded() {
1692
+        guard monitoringMeter == nil || session.status.isOpen == false else {
1693
+            return
1694
+        }
1695
+        storedMeasurements.resetSeries()
1696
+        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1697
+            from: session,
1698
+            replacingLiveBufferIfNeeded: true
1699
+        )
1700
+    }
1701
+
1702
+    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1703
+        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1704
+    }
1705
+
1706
+    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1707
+        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1708
+        let end = normalizedEnd(range.upperBound)
1709
+        onStopWithTrim(start, end)
1710
+    }
1711
+
1712
+    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1713
+        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1714
+    }
1715
+
1716
+    private func normalizedStart(_ date: Date) -> Date? {
1717
+        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1718
+    }
1719
+
1720
+    private func normalizedEnd(_ date: Date) -> Date? {
1721
+        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1722
+    }
1723
+}
1724
+
1725
+private struct ChargeSessionStopRequest: Identifiable {
1726
+    let sessionID: UUID
1727
+    let title: String
1728
+    let confirmTitle: String
1729
+    let explanation: String
1730
+    let appliesTrim: Bool
1731
+    let trimStart: Date?
1732
+    let trimEnd: Date?
1733
+
1734
+    var id: String {
1735
+        [
1736
+            sessionID.uuidString,
1737
+            title,
1738
+            trimStart?.timeIntervalSince1970.description ?? "nil",
1739
+            trimEnd?.timeIntervalSince1970.description ?? "nil"
1740
+        ].joined(separator: "-")
1741
+    }
1742
+}
1743
+
1744
+private extension View {
1745
+    func monitoringActionStyle(tint: Color) -> some View {
1746
+        frame(maxWidth: .infinity)
1747
+            .padding(.vertical, 10)
1748
+            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1749
+            .buttonStyle(.plain)
1750
+    }
1751
+
1752
+    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1753
+        frame(maxWidth: .infinity)
1754
+            .padding(.vertical, 9)
1755
+            .meterCard(
1756
+                tint: tint,
1757
+                fillOpacity: isProminent ? 0.22 : 0.10,
1758
+                strokeOpacity: isProminent ? 0.32 : 0.14,
1759
+                cornerRadius: 14
1760
+            )
1761
+            .buttonStyle(.plain)
1762
+    }
1763
+}
+0 -537
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceActiveSessionView.swift
@@ -1,537 +0,0 @@
1
-//
2
-//  ChargedDeviceActiveSessionView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Codex on 22/04/2026.
6
-//
7
-
8
-import SwiftUI
9
-
10
-struct ChargedDeviceActiveSessionView: View {
11
-    @EnvironmentObject private var appData: AppData
12
-    @State private var targetNotificationEditorVisibility = false
13
-    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
14
-    @State private var pendingSessionStopRequest: ActiveDeviceSessionStopRequest?
15
-
16
-    let chargedDeviceID: UUID
17
-
18
-    private var chargedDevice: ChargedDeviceSummary? {
19
-        appData.chargedDeviceSummary(id: chargedDeviceID)
20
-    }
21
-
22
-    private var activeSession: ChargeSessionSummary? {
23
-        chargedDevice?.activeSession
24
-    }
25
-
26
-    var body: some View {
27
-        Group {
28
-            if let chargedDevice, let activeSession {
29
-                ScrollView {
30
-                    VStack(spacing: 16) {
31
-                        activeSessionCard(activeSession, chargedDevice: chargedDevice)
32
-
33
-                        if !activeSession.displayedAggregatedSamples.isEmpty {
34
-                            storedCurveCard(activeSession)
35
-                        }
36
-                    }
37
-                    .padding()
38
-                }
39
-                .background(
40
-                    LinearGradient(
41
-                        colors: [statusTint(for: activeSession).opacity(0.14), Color.clear],
42
-                        startPoint: .topLeading,
43
-                        endPoint: .bottomTrailing
44
-                    )
45
-                    .ignoresSafeArea()
46
-                )
47
-                .navigationTitle("Current Session")
48
-            } else {
49
-                Text("There is no open session for this device.")
50
-                    .foregroundColor(.secondary)
51
-                    .navigationTitle("Current Session")
52
-            }
53
-        }
54
-        .sheet(isPresented: $targetNotificationEditorVisibility) {
55
-            if let activeSession {
56
-                ActiveSessionTargetNotificationEditorSheetView(
57
-                    sessionID: activeSession.id,
58
-                    initialTargetPercent: activeSession.targetBatteryPercent
59
-                )
60
-                .environmentObject(appData)
61
-            }
62
-        }
63
-        .sheet(item: $pendingSessionStopRequest) { request in
64
-            ChargeSessionCompletionSheetView(
65
-                sessionID: request.sessionID,
66
-                title: request.title,
67
-                confirmTitle: request.confirmTitle,
68
-                explanation: request.explanation
69
-            )
70
-            .environmentObject(appData)
71
-        }
72
-        .alert(item: $pendingCheckpointDeletion) { checkpoint in
73
-            Alert(
74
-                title: Text("Delete Battery Checkpoint"),
75
-                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
76
-                primaryButton: .destructive(Text("Delete")) {
77
-                    _ = appData.deleteBatteryCheckpoint(
78
-                        checkpointID: checkpoint.id,
79
-                        for: checkpoint.sessionID
80
-                    )
81
-                },
82
-                secondaryButton: .cancel()
83
-            )
84
-        }
85
-    }
86
-
87
-    private func activeSessionCard(
88
-        _ activeSession: ChargeSessionSummary,
89
-        chargedDevice: ChargedDeviceSummary
90
-    ) -> some View {
91
-        MeterInfoCardView(title: "Open Session", tint: statusTint(for: activeSession)) {
92
-            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
93
-            MeterInfoRowView(label: "Status", value: activeSession.status.title)
94
-            MeterInfoRowView(label: "Duration", value: sessionDurationText(activeSession))
95
-            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
96
-            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
97
-            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
98
-            if activeSession.chargingTransportMode == .wireless,
99
-               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
100
-               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
101
-                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
102
-            }
103
-            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
104
-            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
105
-            if chargedDevice.isCharger == false,
106
-               let chargerID = activeSession.chargerID,
107
-               let charger = appData.chargedDeviceSummary(id: chargerID) {
108
-                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
109
-            }
110
-            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
111
-                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
112
-            }
113
-            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
114
-                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
115
-            }
116
-            if activeSession.chargingTransportMode == .wired,
117
-               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
118
-                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
119
-            }
120
-            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
121
-                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
122
-            }
123
-            if let targetBatteryPercent = activeSession.targetBatteryPercent {
124
-                MeterInfoRowView(
125
-                    label: "Target Notification",
126
-                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
127
-                )
128
-            }
129
-            if let sessionWarning = sessionWarning(for: activeSession) {
130
-                Text(sessionWarning)
131
-                    .font(.caption2)
132
-                    .foregroundColor(.orange)
133
-            }
134
-            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
135
-                Text(wirelessSessionHint)
136
-                    .font(.caption2)
137
-                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
138
-            }
139
-            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
140
-                MeterInfoRowView(
141
-                    label: "Predicted Battery",
142
-                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
143
-                )
144
-                Text(
145
-                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
146
-                )
147
-                .font(.caption2)
148
-                .foregroundColor(.secondary)
149
-            }
150
-
151
-            BatteryCheckpointSectionView(
152
-                sessionID: activeSession.id,
153
-                checkpoints: activeSession.checkpoints,
154
-                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
155
-                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
156
-                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
157
-                effectiveEnergyWhOverride: nil,
158
-                measuredChargeAhOverride: nil,
159
-                onDelete: { checkpoint in
160
-                    pendingCheckpointDeletion = checkpoint
161
-                }
162
-            )
163
-
164
-            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
165
-                targetNotificationEditorVisibility = true
166
-            }
167
-            .frame(maxWidth: .infinity)
168
-            .padding(.vertical, 10)
169
-            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
170
-            .buttonStyle(.plain)
171
-
172
-            if activeSession.targetBatteryPercent != nil {
173
-                Button("Clear Target Notification") {
174
-                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
175
-                }
176
-                .frame(maxWidth: .infinity)
177
-                .padding(.vertical, 10)
178
-                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
179
-                .buttonStyle(.plain)
180
-            }
181
-
182
-            if activeSession.status == .active {
183
-                Button("Pause Session") {
184
-                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
185
-                }
186
-                .frame(maxWidth: .infinity)
187
-                .padding(.vertical, 10)
188
-                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
189
-                .buttonStyle(.plain)
190
-            } else if activeSession.status == .paused {
191
-                Button("Resume Session") {
192
-                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
193
-                }
194
-                .frame(maxWidth: .infinity)
195
-                .padding(.vertical, 10)
196
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
197
-                .buttonStyle(.plain)
198
-
199
-                Text("Paused sessions close automatically after 10 minutes.")
200
-                    .font(.caption2)
201
-                    .foregroundColor(.secondary)
202
-            }
203
-
204
-            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
205
-                pendingSessionStopRequest = ActiveDeviceSessionStopRequest(
206
-                    sessionID: activeSession.id,
207
-                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
208
-                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
209
-                    explanation: "Add the final battery checkpoint before closing this session."
210
-                )
211
-            }
212
-            .frame(maxWidth: .infinity)
213
-            .padding(.vertical, 10)
214
-            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
215
-            .buttonStyle(.plain)
216
-
217
-            if activeSession.requiresCompletionConfirmation {
218
-                Divider()
219
-                if let contradictionPercent = activeSession.completionContradictionPercent {
220
-                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
221
-                        .font(.caption2)
222
-                        .foregroundColor(.secondary)
223
-                }
224
-
225
-                Button("Keep Monitoring") {
226
-                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
227
-                }
228
-                .frame(maxWidth: .infinity)
229
-                .padding(.vertical, 10)
230
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
231
-                .buttonStyle(.plain)
232
-            }
233
-        }
234
-    }
235
-
236
-    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
237
-        let displayedSamples = session.displayedAggregatedSamples
238
-        let currentSeries = storedSeriesSnapshot(
239
-            from: displayedSamples,
240
-            minimumYSpan: 0.15
241
-        ) { $0.averageCurrentAmps }
242
-        let energySeries = storedSeriesSnapshot(
243
-            from: displayedSamples,
244
-            minimumYSpan: 0.2
245
-        ) { $0.measuredEnergyWh }
246
-
247
-        return VStack(alignment: .leading, spacing: 14) {
248
-            HStack(alignment: .firstTextBaseline) {
249
-                VStack(alignment: .leading, spacing: 4) {
250
-                    HStack(spacing: 8) {
251
-                        Text("Stored Session Curve")
252
-                            .font(.headline)
253
-                        ContextInfoButton(
254
-                            title: "Stored Session Curve",
255
-                            message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress."
256
-                        )
257
-                    }
258
-                    Text("Open session, persisted as aggregated samples.")
259
-                        .font(.caption)
260
-                        .foregroundColor(.secondary)
261
-                }
262
-
263
-                Spacer()
264
-
265
-                Text("\(displayedSamples.count) points")
266
-                    .font(.caption.weight(.semibold))
267
-                    .foregroundColor(.secondary)
268
-            }
269
-
270
-            if let currentSeries {
271
-                storedSeriesChart(
272
-                    title: "Current",
273
-                    unit: "A",
274
-                    strokeColor: .blue,
275
-                    snapshot: currentSeries
276
-                )
277
-            }
278
-
279
-            if let energySeries {
280
-                storedSeriesChart(
281
-                    title: "Energy",
282
-                    unit: "Wh",
283
-                    strokeColor: .teal,
284
-                    areaChart: true,
285
-                    snapshot: energySeries
286
-                )
287
-            }
288
-        }
289
-        .frame(maxWidth: .infinity, alignment: .leading)
290
-        .padding(18)
291
-        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
292
-    }
293
-
294
-    private func storedSeriesSnapshot(
295
-        from samples: [ChargeSessionSampleSummary],
296
-        minimumYSpan: Double,
297
-        value: (ChargeSessionSampleSummary) -> Double
298
-    ) -> ActiveSessionSeriesSnapshot? {
299
-        let sortedSamples = samples.sorted { lhs, rhs in
300
-            if lhs.bucketIndex != rhs.bucketIndex {
301
-                return lhs.bucketIndex < rhs.bucketIndex
302
-            }
303
-            return lhs.timestamp < rhs.timestamp
304
-        }
305
-
306
-        guard
307
-            let firstSample = sortedSamples.first,
308
-            let lastSample = sortedSamples.last
309
-        else {
310
-            return nil
311
-        }
312
-
313
-        let points = sortedSamples.enumerated().map { index, sample in
314
-            Measurements.Measurement.Point(
315
-                id: index,
316
-                timestamp: sample.timestamp,
317
-                value: value(sample),
318
-                kind: .sample
319
-            )
320
-        }
321
-
322
-        let minimumValue = points.map(\.value).min() ?? 0
323
-        let maximumValue = points.map(\.value).max() ?? minimumValue
324
-        let context = ChartContext()
325
-        context.setBounds(
326
-            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
327
-            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
328
-            yMin: CGFloat(minimumValue),
329
-            yMax: CGFloat(maximumValue)
330
-        )
331
-        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
332
-
333
-        return ActiveSessionSeriesSnapshot(
334
-            points: points,
335
-            context: context,
336
-            minimumValue: minimumValue,
337
-            maximumValue: maximumValue
338
-        )
339
-    }
340
-
341
-    private func storedSeriesChart(
342
-        title: String,
343
-        unit: String,
344
-        strokeColor: Color,
345
-        areaChart: Bool = false,
346
-        snapshot: ActiveSessionSeriesSnapshot
347
-    ) -> some View {
348
-        VStack(alignment: .leading, spacing: 8) {
349
-            HStack(alignment: .firstTextBaseline) {
350
-                Text(title)
351
-                    .font(.subheadline.weight(.semibold))
352
-                Spacer()
353
-                Text(
354
-                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
355
-                )
356
-                .font(.caption2)
357
-                .foregroundColor(.secondary)
358
-            }
359
-
360
-            TimeSeriesChart(
361
-                points: snapshot.points,
362
-                context: snapshot.context,
363
-                areaChart: areaChart,
364
-                strokeColor: strokeColor
365
-            )
366
-            .frame(height: 118)
367
-            .padding(.horizontal, 6)
368
-            .padding(.vertical, 8)
369
-            .background(
370
-                RoundedRectangle(cornerRadius: 16)
371
-                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
372
-            )
373
-
374
-            HStack {
375
-                Text(snapshot.startLabel)
376
-                Spacer()
377
-                Text(snapshot.endLabel)
378
-            }
379
-            .font(.caption2)
380
-            .foregroundColor(.secondary)
381
-        }
382
-    }
383
-
384
-    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
385
-        let formatter = DateComponentsFormatter()
386
-        let effectiveDuration = max(session.effectiveDuration, 0)
387
-        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
388
-        formatter.unitsStyle = .abbreviated
389
-        formatter.zeroFormattingBehavior = .dropAll
390
-        return formatter.string(from: effectiveDuration) ?? "0m"
391
-    }
392
-
393
-    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
394
-        if session.autoStopEnabled == false {
395
-            return "Manual"
396
-        }
397
-
398
-        if let sessionWarning = sessionWarning(for: session),
399
-           sessionWarning.contains("idle-current") {
400
-            return "Blocked by charger setup"
401
-        }
402
-
403
-        if session.stopThresholdAmps > 0 {
404
-            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
405
-        }
406
-
407
-        return "Learning"
408
-    }
409
-
410
-    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
411
-        guard session.chargingTransportMode == .wireless,
412
-              let chargerID = session.chargerID,
413
-              let charger = appData.chargedDeviceSummary(id: chargerID),
414
-              charger.chargerIdleCurrentAmps == nil else {
415
-            return nil
416
-        }
417
-
418
-        return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session."
419
-    }
420
-
421
-    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
422
-        guard session.chargingTransportMode == .wireless else {
423
-            return nil
424
-        }
425
-
426
-        var components: [String] = []
427
-        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
428
-            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
429
-        }
430
-        if session.usesEstimatedWirelessEfficiency {
431
-            components.append("Estimated from wired baseline and checkpoints")
432
-        }
433
-        if session.shouldWarnAboutLowWirelessEfficiency {
434
-            components.append("Low wireless efficiency, so capacity confidence is reduced")
435
-        }
436
-
437
-        return components.isEmpty ? nil : components.joined(separator: " - ")
438
-    }
439
-
440
-    private func statusTint(for session: ChargeSessionSummary) -> Color {
441
-        switch session.status {
442
-        case .active:
443
-            return .green
444
-        case .paused:
445
-            return .orange
446
-        case .completed:
447
-            return .teal
448
-        case .abandoned:
449
-            return .secondary
450
-        }
451
-    }
452
-}
453
-
454
-private struct ActiveSessionSeriesSnapshot {
455
-    let points: [Measurements.Measurement.Point]
456
-    let context: ChartContext
457
-    let minimumValue: Double
458
-    let maximumValue: Double
459
-
460
-    var lastValue: Double {
461
-        points.last?.value ?? 0
462
-    }
463
-
464
-    var startLabel: String {
465
-        guard let firstTimestamp = points.first?.timestamp else { return "" }
466
-        return firstTimestamp.formatted(date: .omitted, time: .shortened)
467
-    }
468
-
469
-    var endLabel: String {
470
-        guard let lastTimestamp = points.last?.timestamp else { return "" }
471
-        return lastTimestamp.formatted(date: .omitted, time: .shortened)
472
-    }
473
-}
474
-
475
-private struct ActiveSessionTargetNotificationEditorSheetView: View {
476
-    @Environment(\.dismiss) private var dismiss
477
-    @EnvironmentObject private var appData: AppData
478
-
479
-    let sessionID: UUID
480
-    let initialTargetPercent: Double?
481
-
482
-    @State private var targetPercent: Double
483
-
484
-    init(sessionID: UUID, initialTargetPercent: Double?) {
485
-        self.sessionID = sessionID
486
-        self.initialTargetPercent = initialTargetPercent
487
-        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
488
-    }
489
-
490
-    var body: some View {
491
-        NavigationView {
492
-            Form {
493
-                Section(
494
-                    header: ContextInfoHeader(
495
-                        title: "Target Level",
496
-                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
497
-                    )
498
-                ) {
499
-                    VStack(alignment: .leading, spacing: 12) {
500
-                        Text("\(targetPercent.format(decimalDigits: 0))%")
501
-                            .font(.title3.weight(.bold))
502
-                        Slider(value: $targetPercent, in: 20...100, step: 1)
503
-                    }
504
-                }
505
-            }
506
-            .navigationTitle("Battery Target")
507
-            .navigationBarTitleDisplayMode(.inline)
508
-            .toolbar {
509
-                ToolbarItem(placement: .cancellationAction) {
510
-                    Button("Cancel") {
511
-                        dismiss()
512
-                    }
513
-                }
514
-
515
-                ToolbarItem(placement: .confirmationAction) {
516
-                    Button("Save") {
517
-                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
518
-                            dismiss()
519
-                        }
520
-                    }
521
-                }
522
-            }
523
-        }
524
-        .navigationViewStyle(StackNavigationViewStyle())
525
-    }
526
-}
527
-
528
-private struct ActiveDeviceSessionStopRequest: Identifiable {
529
-    let sessionID: UUID
530
-    let title: String
531
-    let confirmTitle: String
532
-    let explanation: String
533
-
534
-    var id: UUID {
535
-        sessionID
536
-    }
537
-}
+0 -427
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceSessionDetailView.swift
@@ -1,427 +0,0 @@
1
-//
2
-//  ChargedDeviceSessionDetailView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Codex on 22/04/2026.
6
-//
7
-
8
-import SwiftUI
9
-
10
-struct ChargedDeviceSessionDetailView: View {
11
-    @EnvironmentObject private var appData: AppData
12
-    @State private var pendingSessionDeletion: ChargeSessionSummary?
13
-    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
14
-
15
-    let chargedDeviceID: UUID
16
-    let sessionID: UUID
17
-
18
-    private var chargedDevice: ChargedDeviceSummary? {
19
-        appData.chargedDeviceSummary(id: chargedDeviceID)
20
-    }
21
-
22
-    private var session: ChargeSessionSummary? {
23
-        chargedDevice?.sessions.first(where: { $0.id == sessionID })
24
-    }
25
-
26
-    var body: some View {
27
-        Group {
28
-            if let chargedDevice, let session {
29
-                ScrollView {
30
-                    VStack(spacing: 16) {
31
-                        overviewCard(session, chargedDevice: chargedDevice)
32
-                        energyCard(session, chargedDevice: chargedDevice)
33
-                        observedMetricsCard(session, chargedDevice: chargedDevice)
34
-                        batteryCard(session)
35
-
36
-                        if !session.displayedAggregatedSamples.isEmpty {
37
-                            storedCurveCard(session)
38
-                        }
39
-
40
-                        managementCard(session)
41
-                    }
42
-                    .padding()
43
-                }
44
-                .background(
45
-                    LinearGradient(
46
-                        colors: [statusTint(for: session).opacity(0.14), Color.clear],
47
-                        startPoint: .topLeading,
48
-                        endPoint: .bottomTrailing
49
-                    )
50
-                    .ignoresSafeArea()
51
-                )
52
-                .navigationTitle("Session Details")
53
-            } else {
54
-                Text("This session is no longer available.")
55
-                    .foregroundColor(.secondary)
56
-                    .navigationTitle("Session")
57
-            }
58
-        }
59
-        .alert(item: $pendingSessionDeletion) { session in
60
-            Alert(
61
-                title: Text("Delete Session?"),
62
-                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
63
-                primaryButton: .destructive(Text("Delete")) {
64
-                    _ = appData.deleteChargeSession(sessionID: session.id)
65
-                },
66
-                secondaryButton: .cancel()
67
-            )
68
-        }
69
-        .alert(item: $pendingCheckpointDeletion) { checkpoint in
70
-            Alert(
71
-                title: Text("Delete Battery Checkpoint"),
72
-                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
73
-                primaryButton: .destructive(Text("Delete")) {
74
-                    _ = appData.deleteBatteryCheckpoint(
75
-                        checkpointID: checkpoint.id,
76
-                        for: checkpoint.sessionID
77
-                    )
78
-                },
79
-                secondaryButton: .cancel()
80
-            )
81
-        }
82
-    }
83
-
84
-    private func overviewCard(
85
-        _ session: ChargeSessionSummary,
86
-        chargedDevice: ChargedDeviceSummary
87
-    ) -> some View {
88
-        MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) {
89
-            MeterInfoRowView(label: "Device", value: chargedDevice.name)
90
-            MeterInfoRowView(label: "Status", value: session.status.title)
91
-            MeterInfoRowView(label: "Started", value: session.startedAt.format())
92
-            if let endedAt = session.endedAt {
93
-                MeterInfoRowView(label: "Ended", value: endedAt.format())
94
-            }
95
-            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
96
-            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
97
-            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
98
-            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
99
-            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
100
-            if session.isTrimmed {
101
-                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
102
-                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
103
-            }
104
-            if let meterName = session.meterName {
105
-                MeterInfoRowView(label: "Meter", value: meterName)
106
-            } else if let meterMACAddress = session.meterMACAddress {
107
-                MeterInfoRowView(label: "Meter", value: meterMACAddress)
108
-            }
109
-            if let meterModel = session.meterModel {
110
-                MeterInfoRowView(label: "Meter Model", value: meterModel)
111
-            }
112
-        }
113
-    }
114
-
115
-    private func energyCard(
116
-        _ session: ChargeSessionSummary,
117
-        chargedDevice: ChargedDeviceSummary
118
-    ) -> some View {
119
-        MeterInfoCardView(title: "Energy", tint: .teal) {
120
-            MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
121
-            MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
122
-            MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah")
123
-            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
124
-               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
125
-                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
126
-            }
127
-            if let capacityEstimateWh = session.capacityEstimateWh {
128
-                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
129
-            }
130
-            if let chargerID = session.chargerID,
131
-               let charger = appData.chargedDeviceSummary(id: chargerID) {
132
-                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
133
-            }
134
-            if let wirelessSessionHint = wirelessSessionHint(for: session) {
135
-                Text(wirelessSessionHint)
136
-                    .font(.caption2)
137
-                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
138
-            }
139
-        }
140
-    }
141
-
142
-    private func observedMetricsCard(
143
-        _ session: ChargeSessionSummary,
144
-        chargedDevice: ChargedDeviceSummary
145
-    ) -> some View {
146
-        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
147
-            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
148
-                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
149
-            }
150
-            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
151
-                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
152
-            }
153
-            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
154
-                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
155
-            }
156
-            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
157
-                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
158
-            }
159
-            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
160
-                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
161
-            }
162
-            if let completionCurrentAmps = session.completionCurrentAmps {
163
-                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
164
-            }
165
-            if session.selectedDataGroup != nil {
166
-                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
167
-            }
168
-        }
169
-    }
170
-
171
-    private func batteryCard(_ session: ChargeSessionSummary) -> some View {
172
-        MeterInfoCardView(title: "Battery", tint: .orange) {
173
-            if let startBatteryPercent = session.startBatteryPercent {
174
-                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
175
-            }
176
-            if let endBatteryPercent = session.endBatteryPercent {
177
-                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
178
-            }
179
-            if let batteryDeltaPercent = session.batteryDeltaPercent {
180
-                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
181
-            }
182
-
183
-            BatteryCheckpointSectionView(
184
-                sessionID: session.id,
185
-                checkpoints: session.checkpoints,
186
-                message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
187
-                canAddCheckpoint: false,
188
-                requirementMessage: nil,
189
-                effectiveEnergyWhOverride: nil,
190
-                measuredChargeAhOverride: nil,
191
-                onDelete: { checkpoint in
192
-                    pendingCheckpointDeletion = checkpoint
193
-                }
194
-            )
195
-        }
196
-    }
197
-
198
-    private func managementCard(_ session: ChargeSessionSummary) -> some View {
199
-        MeterInfoCardView(title: "Administration", tint: .red) {
200
-            Button(role: .destructive) {
201
-                pendingSessionDeletion = session
202
-            } label: {
203
-                Label("Delete Session", systemImage: "trash")
204
-                    .font(.subheadline.weight(.semibold))
205
-                    .frame(maxWidth: .infinity)
206
-                    .padding(.vertical, 10)
207
-                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
208
-            }
209
-            .buttonStyle(.plain)
210
-        }
211
-    }
212
-
213
-    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
214
-        let displayedSamples = session.displayedAggregatedSamples
215
-        let currentSeries = storedSeriesSnapshot(
216
-            from: displayedSamples,
217
-            minimumYSpan: 0.15
218
-        ) { $0.averageCurrentAmps }
219
-        let energySeries = storedSeriesSnapshot(
220
-            from: displayedSamples,
221
-            minimumYSpan: 0.2
222
-        ) { $0.measuredEnergyWh }
223
-
224
-        return VStack(alignment: .leading, spacing: 14) {
225
-            HStack(alignment: .firstTextBaseline) {
226
-                VStack(alignment: .leading, spacing: 4) {
227
-                    Text("Session Curve")
228
-                        .font(.headline)
229
-                    Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.")
230
-                        .font(.caption)
231
-                        .foregroundColor(.secondary)
232
-                }
233
-
234
-                Spacer()
235
-
236
-                Text("\(displayedSamples.count) points")
237
-                    .font(.caption.weight(.semibold))
238
-                    .foregroundColor(.secondary)
239
-            }
240
-
241
-            if let currentSeries {
242
-                storedSeriesChart(
243
-                    title: "Current",
244
-                    unit: "A",
245
-                    strokeColor: .blue,
246
-                    snapshot: currentSeries
247
-                )
248
-            }
249
-
250
-            if let energySeries {
251
-                storedSeriesChart(
252
-                    title: "Energy",
253
-                    unit: "Wh",
254
-                    strokeColor: .teal,
255
-                    areaChart: true,
256
-                    snapshot: energySeries
257
-                )
258
-            }
259
-        }
260
-        .frame(maxWidth: .infinity, alignment: .leading)
261
-        .padding(18)
262
-        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
263
-    }
264
-
265
-    private func storedSeriesSnapshot(
266
-        from samples: [ChargeSessionSampleSummary],
267
-        minimumYSpan: Double,
268
-        value: (ChargeSessionSampleSummary) -> Double
269
-    ) -> StoredSessionSeriesSnapshot? {
270
-        let sortedSamples = samples.sorted { lhs, rhs in
271
-            if lhs.bucketIndex != rhs.bucketIndex {
272
-                return lhs.bucketIndex < rhs.bucketIndex
273
-            }
274
-            return lhs.timestamp < rhs.timestamp
275
-        }
276
-
277
-        guard
278
-            let firstSample = sortedSamples.first,
279
-            let lastSample = sortedSamples.last
280
-        else {
281
-            return nil
282
-        }
283
-
284
-        let points = sortedSamples.enumerated().map { index, sample in
285
-            Measurements.Measurement.Point(
286
-                id: index,
287
-                timestamp: sample.timestamp,
288
-                value: value(sample),
289
-                kind: .sample
290
-            )
291
-        }
292
-
293
-        let minimumValue = points.map(\.value).min() ?? 0
294
-        let maximumValue = points.map(\.value).max() ?? minimumValue
295
-        let context = ChartContext()
296
-        context.setBounds(
297
-            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
298
-            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
299
-            yMin: CGFloat(minimumValue),
300
-            yMax: CGFloat(maximumValue)
301
-        )
302
-        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
303
-
304
-        return StoredSessionSeriesSnapshot(
305
-            points: points,
306
-            context: context,
307
-            minimumValue: minimumValue,
308
-            maximumValue: maximumValue
309
-        )
310
-    }
311
-
312
-    private func storedSeriesChart(
313
-        title: String,
314
-        unit: String,
315
-        strokeColor: Color,
316
-        areaChart: Bool = false,
317
-        snapshot: StoredSessionSeriesSnapshot
318
-    ) -> some View {
319
-        VStack(alignment: .leading, spacing: 8) {
320
-            HStack(alignment: .firstTextBaseline) {
321
-                Text(title)
322
-                    .font(.subheadline.weight(.semibold))
323
-                Spacer()
324
-                Text(
325
-                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
326
-                )
327
-                .font(.caption2)
328
-                .foregroundColor(.secondary)
329
-            }
330
-
331
-            TimeSeriesChart(
332
-                points: snapshot.points,
333
-                context: snapshot.context,
334
-                areaChart: areaChart,
335
-                strokeColor: strokeColor
336
-            )
337
-            .frame(height: 118)
338
-            .padding(.horizontal, 6)
339
-            .padding(.vertical, 8)
340
-            .background(
341
-                RoundedRectangle(cornerRadius: 16)
342
-                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
343
-            )
344
-
345
-            HStack {
346
-                Text(snapshot.startLabel)
347
-                Spacer()
348
-                Text(snapshot.endLabel)
349
-            }
350
-            .font(.caption2)
351
-            .foregroundColor(.secondary)
352
-        }
353
-    }
354
-
355
-    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
356
-        let formatter = DateComponentsFormatter()
357
-        let effectiveDuration = max(session.effectiveDuration, 0)
358
-        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
359
-        formatter.unitsStyle = .abbreviated
360
-        formatter.zeroFormattingBehavior = .dropAll
361
-        return formatter.string(from: effectiveDuration) ?? "0m"
362
-    }
363
-
364
-    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
365
-        if session.autoStopEnabled == false {
366
-            return "Manual"
367
-        }
368
-        if session.stopThresholdAmps > 0 {
369
-            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
370
-        }
371
-        return "Learning"
372
-    }
373
-
374
-    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
375
-        guard session.chargingTransportMode == .wireless else {
376
-            return nil
377
-        }
378
-
379
-        var components: [String] = []
380
-        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
381
-            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
382
-        }
383
-        if session.usesEstimatedWirelessEfficiency {
384
-            components.append("Estimated from wired baseline and checkpoints")
385
-        }
386
-        if session.shouldWarnAboutLowWirelessEfficiency {
387
-            components.append("Low wireless efficiency, so capacity confidence is reduced")
388
-        }
389
-
390
-        return components.isEmpty ? nil : components.joined(separator: " - ")
391
-    }
392
-
393
-    private func statusTint(for session: ChargeSessionSummary) -> Color {
394
-        switch session.status {
395
-        case .active:
396
-            return .green
397
-        case .paused:
398
-            return .orange
399
-        case .completed:
400
-            return .teal
401
-        case .abandoned:
402
-            return .secondary
403
-        }
404
-    }
405
-}
406
-
407
-private struct StoredSessionSeriesSnapshot {
408
-    let points: [Measurements.Measurement.Point]
409
-    let context: ChartContext
410
-    let minimumValue: Double
411
-    let maximumValue: Double
412
-
413
-    var lastValue: Double {
414
-        points.last?.value ?? 0
415
-    }
416
-
417
-    var startLabel: String {
418
-        guard let firstTimestamp = points.first?.timestamp else { return "" }
419
-        return firstTimestamp.formatted(date: .omitted, time: .shortened)
420
-    }
421
-
422
-    var endLabel: String {
423
-        guard let lastTimestamp = points.last?.timestamp else { return "" }
424
-        return lastTimestamp.formatted(date: .omitted, time: .shortened)
425
-    }
426
-}
427
-
+199 -62
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceSessionsView.swift
@@ -21,6 +21,22 @@ struct ChargedDeviceSessionsView: View {
21 21
         chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
22 22
     }
23 23
 
24
+    /// Maps session ID → capacity delta vs the closest preceding session that has an estimate.
25
+    private var capacityDeltas: [UUID: Double] {
26
+        let sorted = sessions.sorted { $0.startedAt < $1.startedAt }
27
+        var result: [UUID: Double] = [:]
28
+        var previousCapacity: Double? = nil
29
+        for session in sorted {
30
+            if let current = session.capacityEstimateWh {
31
+                if let prev = previousCapacity {
32
+                    result[session.id] = current - prev
33
+                }
34
+                previousCapacity = current
35
+            }
36
+        }
37
+        return result
38
+    }
39
+
24 40
     var body: some View {
25 41
         Group {
26 42
             if let chargedDevice {
@@ -31,8 +47,9 @@ struct ChargedDeviceSessionsView: View {
31 47
                         } else {
32 48
                             summaryHeader(chargedDevice)
33 49
 
50
+                            let deltas = capacityDeltas
34 51
                             ForEach(sessions, id: \.id) { session in
35
-                                sessionCard(session, chargedDevice: chargedDevice)
52
+                                sessionCard(session, chargedDevice: chargedDevice, capacityDelta: deltas[session.id])
36 53
                             }
37 54
                         }
38 55
                     }
@@ -93,15 +110,24 @@ struct ChargedDeviceSessionsView: View {
93 110
         }
94 111
     }
95 112
 
96
-    private func sessionCard(_ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary) -> some View {
97
-        VStack(alignment: .leading, spacing: 10) {
113
+    // MARK: - Session Card
114
+
115
+    private func sessionCard(
116
+        _ session: ChargeSessionSummary,
117
+        chargedDevice: ChargedDeviceSummary,
118
+        capacityDelta: Double?
119
+    ) -> some View {
120
+        let sessionTint = statusTint(for: session)
121
+
122
+        return VStack(alignment: .leading, spacing: 10) {
98 123
             NavigationLink(
99
-                destination: ChargedDeviceSessionDetailView(
124
+                destination: ChargeSessionDetailView(
100 125
                     chargedDeviceID: chargedDevice.id,
101 126
                     sessionID: session.id
102 127
                 )
103 128
             ) {
104 129
                 VStack(alignment: .leading, spacing: 10) {
130
+                    // Header: date + status badge
105 131
                     HStack(alignment: .firstTextBaseline, spacing: 10) {
106 132
                         Text(session.startedAt.format())
107 133
                             .font(.subheadline.weight(.semibold))
@@ -109,13 +135,10 @@ struct ChargedDeviceSessionsView: View {
109 135
 
110 136
                         Text(session.status.title)
111 137
                             .font(.caption2.weight(.semibold))
112
-                            .foregroundColor(statusTint(for: session))
138
+                            .foregroundColor(sessionTint)
113 139
                             .padding(.horizontal, 8)
114 140
                             .padding(.vertical, 4)
115
-                            .background(
116
-                                Capsule()
117
-                                    .fill(statusTint(for: session).opacity(0.16))
118
-                            )
141
+                            .background(Capsule().fill(sessionTint.opacity(0.16)))
119 142
 
120 143
                         Spacer()
121 144
 
@@ -124,19 +147,37 @@ struct ChargedDeviceSessionsView: View {
124 147
                             .foregroundColor(.secondary)
125 148
                     }
126 149
 
127
-                    Text(sessionSummaryLine(session, chargedDevice: chargedDevice))
128
-                        .font(.caption)
129
-                        .foregroundColor(.secondary)
150
+                    // Primary metrics: Energy + Duration
151
+                    HStack(spacing: 8) {
152
+                        primaryMetricCell(
153
+                            label: "Energy",
154
+                            value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
155
+                            tint: .teal
156
+                        )
157
+                        primaryMetricCell(
158
+                            label: "Duration",
159
+                            value: sessionDurationText(session),
160
+                            tint: .orange
161
+                        )
162
+                    }
130 163
 
131
-                    LazyVGrid(columns: metricColumns, spacing: 8) {
132
-                        metricCell(label: "Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal)
133
-                        metricCell(label: "Duration", value: sessionDurationText(session), tint: .orange)
134
-                        if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
135
-                            metricCell(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue)
136
-                        }
137
-                        if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
138
-                            metricCell(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A", tint: .indigo)
139
-                        }
164
+                    // Charge bar (if start/end battery % known)
165
+                    if let chargeRange = chargeBarRange(for: session) {
166
+                        chargeBarView(range: chargeRange, tint: sessionTint)
167
+                    }
168
+
169
+                    // Capacity estimate + battery delta chips
170
+                    let chips = chipContent(session: session, capacityDelta: capacityDelta)
171
+                    if !chips.isEmpty {
172
+                        chipsRow(chips)
173
+                    }
174
+
175
+                    // Secondary info line
176
+                    let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice)
177
+                    if !secondary.isEmpty {
178
+                        Text(secondary)
179
+                            .font(.caption)
180
+                            .foregroundColor(.secondary)
140 181
                     }
141 182
                 }
142 183
             }
@@ -160,33 +201,25 @@ struct ChargedDeviceSessionsView: View {
160 201
                         .font(.caption.weight(.semibold))
161 202
                         .foregroundColor(.red)
162 203
                         .frame(width: 30, height: 30)
163
-                        .background(
164
-                            Circle()
165
-                                .fill(Color.red.opacity(0.10))
166
-                        )
204
+                        .background(Circle().fill(Color.red.opacity(0.10)))
167 205
                 }
168 206
                 .buttonStyle(.plain)
169 207
                 .help("Delete session")
170 208
             }
171 209
         }
172 210
         .padding(14)
173
-        .meterCard(tint: statusTint(for: session), fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
211
+        .meterCard(tint: sessionTint, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
174 212
     }
175 213
 
176
-    private var metricColumns: [GridItem] {
177
-        [
178
-            GridItem(.flexible(minimum: 92), spacing: 8),
179
-            GridItem(.flexible(minimum: 92), spacing: 8)
180
-        ]
181
-    }
214
+    // MARK: - Primary metric cell
182 215
 
183
-    private func metricCell(label: String, value: String, tint: Color) -> some View {
184
-        VStack(alignment: .leading, spacing: 4) {
216
+    private func primaryMetricCell(label: String, value: String, tint: Color) -> some View {
217
+        VStack(alignment: .leading, spacing: 3) {
185 218
             Text(label)
186 219
                 .font(.caption2)
187 220
                 .foregroundColor(.secondary)
188 221
             Text(value)
189
-                .font(.footnote.weight(.semibold))
222
+                .font(.subheadline.weight(.bold))
190 223
                 .foregroundColor(.primary)
191 224
                 .monospacedDigit()
192 225
                 .lineLimit(1)
@@ -197,18 +230,130 @@ struct ChargedDeviceSessionsView: View {
197 230
         .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
198 231
     }
199 232
 
200
-    private func sessionSummaryLine(
233
+    // MARK: - Charge bar
234
+
235
+    /// Returns (startPercent, endPercent) if we have enough data to render a charge bar.
236
+    private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
237
+        let start = session.startBatteryPercent
238
+        let end = session.endBatteryPercent
239
+
240
+        if let s = start, let e = end, e > s {
241
+            return (s, e)
242
+        }
243
+
244
+        // Fall back to first / last checkpoint
245
+        let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
246
+        if sorted.count >= 2,
247
+           let first = sorted.first,
248
+           let last = sorted.last,
249
+           last.batteryPercent > first.batteryPercent {
250
+            return (first.batteryPercent, last.batteryPercent)
251
+        }
252
+
253
+        return nil
254
+    }
255
+
256
+    private func chargeBarView(range: (start: Double, end: Double), tint: Color) -> some View {
257
+        VStack(alignment: .leading, spacing: 4) {
258
+            GeometryReader { geo in
259
+                let w = geo.size.width
260
+                let startX = w * CGFloat(range.start / 100)
261
+                let fillWidth = max(w * CGFloat((range.end - range.start) / 100), 4)
262
+                ZStack(alignment: .leading) {
263
+                    Capsule()
264
+                        .fill(Color.primary.opacity(0.08))
265
+                    // Filled charged portion
266
+                    Rectangle()
267
+                        .fill(LinearGradient(
268
+                            colors: [tint.opacity(0.6), tint],
269
+                            startPoint: .leading,
270
+                            endPoint: .trailing
271
+                        ))
272
+                        .frame(width: fillWidth)
273
+                        .offset(x: startX)
274
+                    // Start marker line
275
+                    if range.start > 3 {
276
+                        Rectangle()
277
+                            .fill(Color.white.opacity(0.5))
278
+                            .frame(width: 1.5, height: 14)
279
+                            .offset(x: startX - 0.75)
280
+                    }
281
+                }
282
+                .clipShape(Capsule())
283
+            }
284
+            .frame(height: 14)
285
+
286
+            // Labels
287
+            HStack {
288
+                Text("\(Int(range.start.rounded()))%")
289
+                    .font(.caption2)
290
+                    .foregroundColor(.secondary)
291
+                Spacer()
292
+                Text("+\(Int((range.end - range.start).rounded()))%")
293
+                    .font(.caption2.weight(.semibold))
294
+                    .foregroundColor(tint)
295
+                Spacer()
296
+                Text("\(Int(range.end.rounded()))%")
297
+                    .font(.caption2)
298
+                    .foregroundColor(.secondary)
299
+            }
300
+        }
301
+    }
302
+
303
+    // MARK: - Chips
304
+
305
+    private struct ChipContent {
306
+        let label: String
307
+        let tint: Color
308
+    }
309
+
310
+    private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
311
+        var chips: [ChipContent] = []
312
+
313
+        if let capacityWh = session.capacityEstimateWh {
314
+            var label = "\(capacityWh.format(decimalDigits: 1)) Wh"
315
+            if let delta = capacityDelta {
316
+                let sign = delta >= 0 ? "+" : ""
317
+                label += " (\(sign)\(delta.format(decimalDigits: 1)))"
318
+            }
319
+            chips.append(ChipContent(label: label, tint: .orange))
320
+        }
321
+
322
+        if let batteryDelta = session.batteryDeltaPercent {
323
+            let sign = batteryDelta >= 0 ? "+" : ""
324
+            chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal))
325
+        }
326
+
327
+        return chips
328
+    }
329
+
330
+    private func chipsRow(_ chips: [ChipContent]) -> some View {
331
+        HStack(spacing: 6) {
332
+            ForEach(chips.indices, id: \.self) { i in
333
+                let chip = chips[i]
334
+                Text(chip.label)
335
+                    .font(.caption2.weight(.semibold))
336
+                    .foregroundColor(chip.tint)
337
+                    .padding(.horizontal, 8)
338
+                    .padding(.vertical, 4)
339
+                    .background(
340
+                        RoundedRectangle(cornerRadius: 8)
341
+                            .fill(chip.tint.opacity(0.14))
342
+                            .overlay(RoundedRectangle(cornerRadius: 8).stroke(chip.tint.opacity(0.22), lineWidth: 1))
343
+                    )
344
+            }
345
+            Spacer()
346
+        }
347
+    }
348
+
349
+    // MARK: - Secondary info line
350
+
351
+    private func secondaryInfoLine(
201 352
         _ session: ChargeSessionSummary,
202 353
         chargedDevice: ChargedDeviceSummary
203 354
     ) -> String {
204 355
         var components: [String] = []
205 356
 
206
-        if let batteryDeltaPercent = session.batteryDeltaPercent {
207
-            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
208
-        }
209
-        if let capacityEstimateWh = session.capacityEstimateWh {
210
-            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
211
-        }
212 357
         if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
213 358
             components.append(session.chargingTransportMode.title)
214 359
         }
@@ -220,9 +365,11 @@ struct ChargedDeviceSessionsView: View {
220 365
         }
221 366
         components.append(session.sourceMode.title)
222 367
 
223
-        return components.joined(separator: " - ")
368
+        return components.joined(separator: " · ")
224 369
     }
225 370
 
371
+    // MARK: - Helpers
372
+
226 373
     private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
227 374
         let formatter = DateComponentsFormatter()
228 375
         let effectiveDuration = max(session.effectiveDuration, 0)
@@ -234,30 +381,20 @@ struct ChargedDeviceSessionsView: View {
234 381
 
235 382
     private func statusTint(for session: ChargeSessionSummary) -> Color {
236 383
         switch session.status {
237
-        case .active:
238
-            return .green
239
-        case .paused:
240
-            return .orange
241
-        case .completed:
242
-            return .teal
243
-        case .abandoned:
244
-            return .secondary
384
+        case .active:   return .green
385
+        case .paused:   return .orange
386
+        case .completed: return .teal
387
+        case .abandoned: return .secondary
245 388
         }
246 389
     }
247 390
 
248 391
     private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
249 392
         switch chargedDevice.deviceClass {
250
-        case .iphone:
251
-            return .blue
252
-        case .watch:
253
-            return .green
254
-        case .powerbank:
255
-            return .orange
256
-        case .charger:
257
-            return .pink
258
-        case .other:
259
-            return .secondary
393
+        case .iphone:    return .blue
394
+        case .watch:     return .green
395
+        case .powerbank: return .orange
396
+        case .charger:   return .pink
397
+        case .other:     return .secondary
260 398
         }
261 399
     }
262 400
 }
263
-
+11 -8
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -180,6 +180,7 @@ struct BatteryCheckpointSectionView: View {
180 180
     let checkpoints: [ChargeCheckpointSummary]
181 181
     let message: String
182 182
     let canAddCheckpoint: Bool
183
+    let canDeleteCheckpoint: Bool
183 184
     let requirementMessage: String?
184 185
     let effectiveEnergyWhOverride: Double?
185 186
     let measuredChargeAhOverride: Double?
@@ -252,15 +253,17 @@ struct BatteryCheckpointSectionView: View {
252 253
                     Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
253 254
                         .font(.caption2)
254 255
                         .foregroundColor(.secondary)
255
-                    Button {
256
-                        onDelete(checkpoint)
257
-                    } label: {
258
-                        Image(systemName: "trash")
259
-                            .font(.caption.weight(.semibold))
260
-                            .foregroundColor(.red)
256
+                    if canDeleteCheckpoint {
257
+                        Button {
258
+                            onDelete(checkpoint)
259
+                        } label: {
260
+                            Image(systemName: "trash")
261
+                                .font(.caption.weight(.semibold))
262
+                                .foregroundColor(.red)
263
+                        }
264
+                        .buttonStyle(.plain)
265
+                        .help("Delete checkpoint")
261 266
                     }
262
-                    .buttonStyle(.plain)
263
-                    .help("Delete checkpoint")
264 267
                 }
265 268
             }
266 269
 
+135 -19
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/ChargeSessionCompletionSheetView.swift
@@ -29,9 +29,34 @@ struct ChargeSessionCompletionSheetView: View {
29 29
     let title: String
30 30
     let confirmTitle: String
31 31
     let explanation: String
32
+    let monitoringMeter: Meter?
33
+    let appliesTrim: Bool
34
+    let trimStart: Date?
35
+    let trimEnd: Date?
32 36
 
33 37
     @State private var batteryPercent = ""
34 38
     @State private var finalCheckpoint: FinalCheckpoint = .skip
39
+    @State private var saveFailureMessage: String?
40
+
41
+    init(
42
+        sessionID: UUID,
43
+        title: String,
44
+        confirmTitle: String,
45
+        explanation: String,
46
+        monitoringMeter: Meter? = nil,
47
+        appliesTrim: Bool = false,
48
+        trimStart: Date? = nil,
49
+        trimEnd: Date? = nil
50
+    ) {
51
+        self.sessionID = sessionID
52
+        self.title = title
53
+        self.confirmTitle = confirmTitle
54
+        self.explanation = explanation
55
+        self.monitoringMeter = monitoringMeter
56
+        self.appliesTrim = appliesTrim
57
+        self.trimStart = trimStart
58
+        self.trimEnd = trimEnd
59
+    }
35 60
 
36 61
     var body: some View {
37 62
         NavigationView {
@@ -56,26 +81,34 @@ struct ChargeSessionCompletionSheetView: View {
56 81
                 }
57 82
 
58 83
                 Section {
59
-                    if let refusalReason {
60
-                        Label(refusalReason, systemImage: "exclamationmark.triangle.fill")
84
+                    if appliesTrim {
85
+                        Label("The selected trim window will be applied before the session is closed.", systemImage: "scissors")
86
+                            .font(.footnote)
87
+                            .foregroundColor(.blue)
88
+                    }
89
+
90
+                    if let saveFailureMessage {
91
+                        Label(saveFailureMessage, systemImage: "exclamationmark.triangle.fill")
61 92
                             .font(.footnote)
62 93
                             .foregroundColor(.red)
94
+                    } else if let saveDisabledReason {
95
+                        Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
96
+                            .font(.footnote)
97
+                            .foregroundColor(.red)
98
+                    }
63 99
 
100
+                    if hasChargeDataToSave == false {
64 101
                         Button(role: .destructive) {
65 102
                             _ = appData.deleteChargeSession(sessionID: sessionID)
66 103
                             dismiss()
67 104
                         } label: {
68 105
                             Label("Discard Session", systemImage: "trash")
69 106
                         }
70
-                    } else if let customCheckpointWarning {
71
-                        Label(customCheckpointWarning, systemImage: "exclamationmark.triangle.fill")
72
-                            .font(.footnote)
73
-                            .foregroundColor(.orange)
74
-                    } else if let sessionWarning {
107
+                    } else if saveDisabledReason == nil, let sessionWarning {
75 108
                         Text(sessionWarning)
76 109
                             .font(.footnote)
77 110
                             .foregroundColor(.orange)
78
-                    } else if resolvedFinalBatteryPercent == 100 {
111
+                    } else if saveDisabledReason == nil, resolvedFinalBatteryPercent == 100 {
79 112
                         Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
80 113
                             .font(.footnote)
81 114
                             .foregroundColor(.secondary)
@@ -92,9 +125,26 @@ struct ChargeSessionCompletionSheetView: View {
92 125
                 }
93 126
                 ToolbarItem(placement: .confirmationAction) {
94 127
                     Button(confirmTitle) {
95
-                        guard canSave else { return }
96
-                        if appData.stopChargeSession(sessionID: sessionID, finalBatteryPercent: resolvedFinalBatteryPercent) {
128
+                        guard canSave else {
129
+                            saveFailureMessage = saveDisabledReason
130
+                            return
131
+                        }
132
+                        if appliesTrim {
133
+                            _ = appData.setSessionTrim(
134
+                                sessionID: sessionID,
135
+                                start: trimStart,
136
+                                end: trimEnd
137
+                            )
138
+                        }
139
+
140
+                        if appData.stopChargeSession(
141
+                            sessionID: sessionID,
142
+                            finalBatteryPercent: resolvedFinalBatteryPercent,
143
+                            from: monitoringMeter
144
+                        ) {
97 145
                             dismiss()
146
+                        } else {
147
+                            saveFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment."
98 148
                         }
99 149
                     }
100 150
                     .disabled(!canSave)
@@ -103,6 +153,17 @@ struct ChargeSessionCompletionSheetView: View {
103 153
             }
104 154
         }
105 155
         .navigationViewStyle(StackNavigationViewStyle())
156
+        .onChange(of: finalCheckpoint) { mode in
157
+            saveFailureMessage = nil
158
+            if mode == .custom {
159
+                prefillFinalCheckpointIfNeeded()
160
+            } else {
161
+                batteryPercent = ""
162
+            }
163
+        }
164
+        .onChange(of: batteryPercent) { _ in
165
+            saveFailureMessage = nil
166
+        }
106 167
     }
107 168
 
108 169
     private var session: ChargeSessionSummary? {
@@ -112,20 +173,32 @@ struct ChargeSessionCompletionSheetView: View {
112 173
     }
113 174
 
114 175
     private var canSave: Bool {
115
-        session?.hasSavableChargeData == true
176
+        saveDisabledReason == nil
116 177
     }
117 178
 
118
-    private var refusalReason: String? {
119
-        canSave ? nil : "This session has no charging data to save. Discard it instead."
179
+    private var hasChargeDataToSave: Bool {
180
+        guard let session else { return false }
181
+        return session.hasSavableChargeData
182
+            || displayedSessionEnergyWh(for: session) > 0
183
+            || displayedSessionChargeAh(for: session) > 0
120 184
     }
121 185
 
122
-    private var customCheckpointWarning: String? {
123
-        guard finalCheckpoint == .custom,
124
-              batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
125
-              parsedBatteryPercent == nil else {
126
-            return nil
186
+    private var saveDisabledReason: String? {
187
+        if finalCheckpoint == .custom {
188
+            let trimmed = batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines)
189
+            if trimmed.isEmpty {
190
+                return "Enter the final battery percentage or choose Skip."
191
+            }
192
+            if parsedBatteryPercent == nil {
193
+                return "Final battery percentage must be between 0 and 100."
194
+            }
195
+        }
196
+
197
+        guard hasChargeDataToSave else {
198
+            return "This session has no charging data to save. Discard it instead."
127 199
         }
128
-        return "Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint."
200
+
201
+        return nil
129 202
     }
130 203
 
131 204
     private var parsedBatteryPercent: Double? {
@@ -144,6 +217,25 @@ struct ChargeSessionCompletionSheetView: View {
144 217
         }
145 218
     }
146 219
 
220
+    private var suggestedFinalBatteryPercent: Double? {
221
+        guard let session else { return nil }
222
+        if let endBatteryPercent = session.endBatteryPercent {
223
+            return endBatteryPercent
224
+        }
225
+        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
226
+            return latestCheckpoint.batteryPercent
227
+        }
228
+        return session.targetBatteryPercent ?? session.completionContradictionPercent
229
+    }
230
+
231
+    private func prefillFinalCheckpointIfNeeded() {
232
+        guard batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
233
+              let suggestedFinalBatteryPercent else {
234
+            return
235
+        }
236
+        batteryPercent = suggestedFinalBatteryPercent.format(decimalDigits: 0)
237
+    }
238
+
147 239
     private var sessionWarning: String? {
148 240
         guard let session,
149 241
               session.chargingTransportMode == .wireless,
@@ -154,4 +246,28 @@ struct ChargeSessionCompletionSheetView: View {
154 246
         }
155 247
         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."
156 248
     }
249
+
250
+    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
251
+        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
252
+        guard session.isTrimmed == false else { return storedEnergyWh }
253
+        guard session.status.isOpen else { return storedEnergyWh }
254
+        guard let monitoringMeter else { return storedEnergyWh }
255
+        guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
256
+        if let baselineEnergyWh = session.meterEnergyBaselineWh {
257
+            return max(storedEnergyWh, max(monitoringMeter.recordedWH - baselineEnergyWh, 0))
258
+        }
259
+        return storedEnergyWh
260
+    }
261
+
262
+    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
263
+        let storedChargeAh = session.measuredChargeAh
264
+        guard session.isTrimmed == false else { return storedChargeAh }
265
+        guard session.status.isOpen else { return storedChargeAh }
266
+        guard let monitoringMeter else { return storedChargeAh }
267
+        guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
268
+        if let baselineChargeAh = session.meterChargeBaselineAh {
269
+            return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0))
270
+        }
271
+        return storedChargeAh
272
+    }
157 273
 }
+300 -2
USB Meter/Views/Meter/MeterView.swift
@@ -115,7 +115,28 @@ struct MeterView: View {
115 115
     @State private var navBarRSSI: Int = 0
116 116
     @State private var landscapeTabBarHeight: CGFloat = 0
117 117
 
118
+    // Offline mode state
119
+    private enum OfflineTab: String { case info, settings }
120
+    @State private var selectedOfflineTab: OfflineTab = .info
121
+    @State private var offlineEditingName: Bool = false
122
+    @State private var offlineName: String = ""
123
+    @State private var offlineDeleteConfirmation: Bool = false
124
+    @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius
125
+
126
+    private let offlineSummary: AppData.MeterSummary?
127
+
128
+    init() { offlineSummary = nil }
129
+    init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary }
130
+
118 131
     var body: some View {
132
+        if let summary = offlineSummary {
133
+            offlineBody(summary: summary)
134
+        } else {
135
+            liveBody
136
+        }
137
+    }
138
+
139
+    private var liveBody: some View {
119 140
         GeometryReader { proxy in
120 141
             let landscape = isLandscape(size: proxy.size)
121 142
             let usesOverlayTabBar = landscape && Self.isPhone
@@ -363,6 +384,7 @@ struct MeterView: View {
363 384
             HStack(spacing: 8) {
364 385
                 ForEach(availableMeterTabs, id: \.self) { tab in
365 386
                     let isSelected = displayedMeterTab == tab
387
+                    let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable
366 388
 
367 389
                     Button {
368 390
                         withAnimation(.easeInOut(duration: 0.2)) {
@@ -381,7 +403,7 @@ struct MeterView: View {
381 403
                         .foregroundColor(
382 404
                             isSelected
383 405
                             ? .white
384
-                            : unselectedForegroundColor
406
+                            : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor)
385 407
                         )
386 408
                         .padding(.horizontal, style.chipHorizontalPadding)
387 409
                         .padding(.vertical, style.chipVerticalPadding)
@@ -391,7 +413,7 @@ struct MeterView: View {
391 413
                                 .fill(
392 414
                                     isSelected
393 415
                                     ? meter.color.opacity(isFloating ? 0.94 : 1)
394
-                                    : unselectedChipFill
416
+                                    : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill)
395 417
                                 )
396 418
                         )
397 419
                     }
@@ -646,6 +668,282 @@ struct MeterView: View {
646 668
         }
647 669
     }
648 670
 
671
+    private func requiresLiveData(_ tab: MeterTab) -> Bool {
672
+        switch tab {
673
+        case .live, .chart: return true
674
+        case .home, .chargeRecord, .dataGroups, .settings: return false
675
+        }
676
+    }
677
+
678
+    private var isLiveDataAvailable: Bool {
679
+        meter.operationalState >= .dataIsAvailable
680
+    }
681
+
682
+    // MARK: - Offline mode
683
+
684
+    @ViewBuilder
685
+    private func offlineBody(summary: AppData.MeterSummary) -> some View {
686
+        VStack(spacing: 0) {
687
+            if Self.isTrueMacApp {
688
+                offlineMacHeader(name: summary.displayName)
689
+            }
690
+            offlineTabBar(tint: summary.tint)
691
+            offlineTabContent(summary: summary)
692
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
693
+                .id(selectedOfflineTab)
694
+                .transition(.opacity.combined(with: .move(edge: .trailing)))
695
+                .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab)
696
+        }
697
+        .background(offlineBackground(tint: summary.tint))
698
+        #if !targetEnvironment(macCatalyst)
699
+        .navigationBarHidden(Self.isTrueMacApp)
700
+        #else
701
+        .navigationBarHidden(false)
702
+        #endif
703
+        .navigationBarTitle(summary.displayName, displayMode: .inline)
704
+        .onAppear {
705
+            offlineName = summary.displayName
706
+            offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress)
707
+        }
708
+    }
709
+
710
+    private func offlineTabBar(tint: Color) -> some View {
711
+        HStack {
712
+            Spacer(minLength: 0)
713
+            HStack(spacing: 8) {
714
+                ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in
715
+                    let isSelected = selectedOfflineTab == tab
716
+                    Button {
717
+                        withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab }
718
+                    } label: {
719
+                        HStack(spacing: 6) {
720
+                            Image(systemName: tab == .info ? "house.fill" : "gearshape.fill")
721
+                                .font(.subheadline.weight(.semibold))
722
+                            Text(tab == .info ? "Info" : "Settings")
723
+                                .font(.subheadline.weight(.semibold))
724
+                                .lineLimit(1)
725
+                        }
726
+                        .foregroundColor(isSelected ? .white : .primary)
727
+                        .padding(.horizontal, 10)
728
+                        .padding(.vertical, 7)
729
+                        .frame(maxWidth: .infinity)
730
+                        .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12)))
731
+                    }
732
+                    .buttonStyle(.plain)
733
+                }
734
+            }
735
+            .padding(6)
736
+            .background(
737
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
738
+                    .fill(Color.secondary.opacity(0.10))
739
+            )
740
+            .background(
741
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
742
+                    .fill(.ultraThinMaterial)
743
+                    .opacity(0.78)
744
+            )
745
+            Spacer(minLength: 0)
746
+        }
747
+        .padding(.horizontal, 16)
748
+        .padding(.top, 10)
749
+        .padding(.bottom, 8)
750
+        .background(
751
+            Rectangle()
752
+                .fill(.ultraThinMaterial)
753
+                .opacity(0.78)
754
+                .ignoresSafeArea(edges: .top)
755
+        )
756
+        .overlay(alignment: .bottom) {
757
+            Rectangle()
758
+                .fill(Color.secondary.opacity(0.12))
759
+                .frame(height: 1)
760
+        }
761
+    }
762
+
763
+    @ViewBuilder
764
+    private func offlineTabContent(summary: AppData.MeterSummary) -> some View {
765
+        switch selectedOfflineTab {
766
+        case .info:
767
+            ScrollView {
768
+                VStack(alignment: .leading, spacing: 20) {
769
+                    offlineStatusHeader(summary: summary)
770
+                    MeterInfoCardView(title: "Meter", tint: summary.tint) {
771
+                        MeterInfoRowView(label: "Name", value: summary.displayName)
772
+                        MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary)
773
+                        if let advertisedName = summary.advertisedName {
774
+                            MeterInfoRowView(label: "Advertised Name", value: advertisedName)
775
+                        }
776
+                        MeterInfoRowView(label: "MAC", value: summary.macAddress)
777
+                        MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen))
778
+                        MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected))
779
+                    }
780
+                }
781
+                .padding(16)
782
+            }
783
+        case .settings:
784
+            offlineSettingsContent(summary: summary)
785
+        }
786
+    }
787
+
788
+    private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View {
789
+        let isTC66 = summary.modelSummary == "TC66C"
790
+        return ScrollView {
791
+            VStack(spacing: 14) {
792
+                offlineSettingsCard(title: "Name", tint: summary.tint) {
793
+                    HStack {
794
+                        Spacer()
795
+                        if !offlineEditingName {
796
+                            Text(offlineName).foregroundColor(.secondary)
797
+                        }
798
+                        ChevronView(rotate: $offlineEditingName)
799
+                    }
800
+                    if offlineEditingName {
801
+                        TextField("Name", text: $offlineName, onCommit: {
802
+                            appData.setMeterName(offlineName, for: summary.macAddress)
803
+                            offlineEditingName = false
804
+                        })
805
+                        .textFieldStyle(RoundedBorderTextFieldStyle())
806
+                        .lineLimit(1)
807
+                        .disableAutocorrection(true)
808
+                        .multilineTextAlignment(.center)
809
+                    }
810
+                }
811
+
812
+                if isTC66 {
813
+                    offlineSettingsCard(
814
+                        title: "Meter Temperature Unit",
815
+                        infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.",
816
+                        tint: .orange
817
+                    ) {
818
+                        Picker("", selection: $offlineTemperatureUnit) {
819
+                            ForEach(TemperatureUnitPreference.allCases) { unit in
820
+                                Text(unit.title).tag(unit)
821
+                            }
822
+                        }
823
+                        .pickerStyle(SegmentedPickerStyle())
824
+                        .onChange(of: offlineTemperatureUnit) { newValue in
825
+                            appData.setTemperatureUnitPreference(newValue, for: summary.macAddress)
826
+                        }
827
+                    }
828
+                }
829
+
830
+                offlineSettingsCard(
831
+                    title: "Danger Zone",
832
+                    infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.",
833
+                    tint: .red
834
+                ) {
835
+                    Button("Delete Meter") {
836
+                        offlineDeleteConfirmation = true
837
+                    }
838
+                    .frame(maxWidth: .infinity)
839
+                    .padding(.vertical, 10)
840
+                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
841
+                    .buttonStyle(.plain)
842
+                }
843
+            }
844
+            .padding()
845
+        }
846
+        .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) {
847
+            Button("Delete", role: .destructive) {
848
+                appData.deleteMeter(macAddress: summary.macAddress)
849
+                dismiss()
850
+            }
851
+            Button("Cancel", role: .cancel) {}
852
+        } message: {
853
+            Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
854
+        }
855
+    }
856
+
857
+    private func offlineSettingsCard<Content: View>(
858
+        title: String,
859
+        infoMessage: String? = nil,
860
+        tint: Color,
861
+        @ViewBuilder content: () -> Content
862
+    ) -> some View {
863
+        VStack(alignment: .leading, spacing: 12) {
864
+            HStack(spacing: 8) {
865
+                Text(title).font(.headline)
866
+                if let infoMessage {
867
+                    ContextInfoButton(title: title, message: infoMessage)
868
+                }
869
+            }
870
+            content()
871
+        }
872
+        .padding(18)
873
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
874
+    }
875
+
876
+    private func offlineMacHeader(name: String) -> some View {
877
+        HStack(spacing: 12) {
878
+            Button { dismiss() } label: {
879
+                HStack(spacing: 4) {
880
+                    Image(systemName: "chevron.left")
881
+                        .font(.body.weight(.semibold))
882
+                    Text("USB Meters")
883
+                }
884
+                .foregroundColor(.accentColor)
885
+            }
886
+            .buttonStyle(.plain)
887
+            Text(name).font(.headline).lineLimit(1)
888
+            Spacer()
889
+        }
890
+        .padding(.horizontal, 16)
891
+        .padding(.vertical, 10)
892
+        .background(
893
+            Rectangle()
894
+                .fill(.ultraThinMaterial)
895
+                .ignoresSafeArea(edges: .top)
896
+        )
897
+        .overlay(alignment: .bottom) {
898
+            Rectangle()
899
+                .fill(Color.secondary.opacity(0.12))
900
+                .frame(height: 1)
901
+        }
902
+    }
903
+
904
+    private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
905
+        HStack(spacing: 12) {
906
+            Image(systemName: "sensor.tag.radiowaves.forward.fill")
907
+                .font(.system(size: 22, weight: .semibold))
908
+                .foregroundColor(.secondary)
909
+            VStack(alignment: .leading, spacing: 4) {
910
+                Text(summary.displayName)
911
+                    .font(.title3.weight(.semibold))
912
+                    .lineLimit(1)
913
+                Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary)
914
+                    .font(.caption)
915
+                    .foregroundColor(.secondary)
916
+            }
917
+            Spacer()
918
+            HStack(spacing: 6) {
919
+                Circle().fill(Color.secondary).frame(width: 8, height: 8)
920
+                Text("Offline")
921
+                    .font(.caption.weight(.semibold))
922
+                    .foregroundColor(.secondary)
923
+            }
924
+            .padding(.horizontal, 10)
925
+            .padding(.vertical, 6)
926
+            .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12)))
927
+            .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1))
928
+        }
929
+        .padding(14)
930
+        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18)
931
+    }
932
+
933
+    private func offlineBackground(tint: Color) -> some View {
934
+        LinearGradient(
935
+            colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear],
936
+            startPoint: .topLeading,
937
+            endPoint: .bottomTrailing
938
+        )
939
+        .ignoresSafeArea()
940
+    }
941
+
942
+    private func historyText(for date: Date?) -> String {
943
+        guard let date else { return "Never" }
944
+        return date.format(as: "yyyy-MM-dd HH:mm")
945
+    }
946
+
649 947
 }
650 948
 
651 949
 private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
+23 -1039
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -16,11 +16,6 @@ struct MeterChargeRecordTabView: View, Equatable {
16 16
 }
17 17
 
18 18
 struct MeterChargeRecordContentView: View {
19
-    private struct SessionMetricRow {
20
-        let label: String
21
-        let value: String
22
-    }
23
-
24 19
     private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
25 20
         case known
26 21
         case unknown
@@ -42,28 +37,6 @@ struct MeterChargeRecordContentView: View {
42 37
         case standbyPower
43 38
     }
44 39
 
45
-    private enum FinalCheckpoint: Hashable {
46
-        case full
47
-        case skip
48
-        case custom
49
-
50
-        var label: String {
51
-            switch self {
52
-            case .full:   return "Full"
53
-            case .skip:   return "Skip"
54
-            case .custom: return "Other %"
55
-            }
56
-        }
57
-
58
-        var icon: String {
59
-            switch self {
60
-            case .full:   return "battery.100percent"
61
-            case .skip:   return "minus.circle"
62
-            case .custom: return "pencil"
63
-            }
64
-        }
65
-    }
66
-
67 40
     private enum SessionStartRequirement: Identifiable {
68 41
         case existingSession
69 42
         case device
@@ -98,63 +71,42 @@ struct MeterChargeRecordContentView: View {
98 71
         }
99 72
     }
100 73
 
101
-@EnvironmentObject private var appData: AppData
74
+    @EnvironmentObject private var appData: AppData
102 75
     @EnvironmentObject private var usbMeter: Meter
103 76
 
104
-    @State private var showingInlineTargetEditor = false
105
-    @State private var draftTargetText = ""
106
-    @State private var showingStopConfirm = false
107
-    @State private var finalCheckpointMode: FinalCheckpoint = .skip
108
-    @State private var finalCheckpointText = ""
109
-    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
110 77
     @State private var draftChargingTransportMode: ChargingTransportMode?
111 78
     @State private var draftChargingStateMode: ChargingStateMode?
112 79
     @State private var initialCheckpointMode: InitialCheckpointMode = .known
113 80
     @State private var initialCheckpoint = ""
114 81
     @State private var showsMeterTotalsInfo = false
115 82
     @State private var activeMode: ActiveMode = .chargeSession
116
-    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
117
-    @State private var trimBannerDismissedForSessionID: UUID?
118
-
119
-    private var shouldShowTrimBanner: Bool {
120
-        guard let session = openChargeSession,
121
-              session.isTrimmed == false,
122
-              trimBannerDismissedForSessionID != session.id else { return false }
123
-        guard let window = detectedTrimWindow else { return false }
124
-        return window.trimRatio > ChargingWindowDetector.significantTrimThreshold
125
-    }
126 83
 
127 84
     var body: some View {
128
-        ScrollView {
129
-            VStack(spacing: 14) {
130
-                statusHeader
131
-
132
-                if let openChargeSession {
133
-                    chargingMonitorCard(openChargeSession)
134
-
135
-                    if shouldShowTrimBanner {
136
-                        trimDetectionBanner(for: openChargeSession)
137
-                    }
138
-
139
-                    if shouldShowSessionChart(for: openChargeSession) {
140
-                        sessionChartCard(
141
-                            timeRange: sessionChartFixedTimeRange(for: openChargeSession),
142
-                            session: openChargeSession
143
-                        )
144
-                    }
145
-                } else {
146
-                    liveMeterStripView
147
-                    modePicker
148
-
149
-                    switch activeMode {
150
-                    case .chargeSession:
151
-                        chargeSessionSetupCard
152
-                    case .standbyPower:
153
-                        standbyPowerCard
85
+        Group {
86
+            if let openChargeSession {
87
+                ChargeSessionDetailView(
88
+                    chargedDeviceID: openChargeSession.chargedDeviceID,
89
+                    sessionID: openChargeSession.id,
90
+                    monitoringMeter: usbMeter,
91
+                    presentation: .embedded
92
+                )
93
+            } else {
94
+                ScrollView {
95
+                    VStack(spacing: 14) {
96
+                        statusHeader
97
+                        liveMeterStripView
98
+                        modePicker
99
+
100
+                        switch activeMode {
101
+                        case .chargeSession:
102
+                            chargeSessionSetupCard
103
+                        case .standbyPower:
104
+                            standbyPowerCard
105
+                        }
154 106
                     }
107
+                    .padding()
155 108
                 }
156 109
             }
157
-            .padding()
158 110
         }
159 111
         .background(
160 112
             LinearGradient(
@@ -164,64 +116,15 @@ struct MeterChargeRecordContentView: View {
164 116
             )
165 117
             .ignoresSafeArea()
166 118
         )
167
-        .alert(item: $pendingCheckpointDeletion) { checkpoint in
168
-            Alert(
169
-                title: Text("Delete Battery Checkpoint"),
170
-                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
171
-                primaryButton: .destructive(Text("Delete")) {
172
-                    if let openChargeSession {
173
-                        _ = appData.deleteBatteryCheckpoint(
174
-                            checkpointID: checkpoint.id,
175
-                            for: openChargeSession.id
176
-                        )
177
-                    }
178
-                },
179
-                secondaryButton: .cancel()
180
-            )
181
-        }
182 119
         .onAppear {
183
-            syncActiveSessionRestore()
184 120
             syncDraftSelections()
185
-            runTrimDetection()
186 121
         }
187 122
         .onChange(of: selectedChargedDevice?.id) { _ in
188 123
             syncDraftSelections()
189 124
         }
190 125
         .onChange(of: openChargeSession?.id) { _ in
191
-            syncActiveSessionRestore()
192 126
             syncDraftSelections()
193
-            showingInlineTargetEditor = false
194
-            draftTargetText = ""
195
-            detectedTrimWindow = nil
196
-            trimBannerDismissedForSessionID = nil
197
-            runTrimDetection()
198
-        }
199
-        .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
200
-            syncActiveSessionRestore()
201
-            runTrimDetection()
202
-        }
203
-    }
204
-
205
-    private func syncActiveSessionRestore() {
206
-        guard let session = openChargeSession else { return }
207
-        guard session.status.isOpen else { return }
208
-        guard session.meterMACAddress == meterMACAddress else { return }
209
-        usbMeter.restoreChargeMonitoringIfNeeded(from: session)
210
-    }
211
-
212
-    private func runTrimDetection() {
213
-        guard let session = openChargeSession,
214
-              session.isTrimmed == false,
215
-              !session.aggregatedSamples.isEmpty else {
216
-            detectedTrimWindow = nil
217
-            return
218 127
         }
219
-        let sessionEnd = session.endedAt ?? session.lastObservedAt
220
-        detectedTrimWindow = ChargingWindowDetector.detect(
221
-            samples: session.aggregatedSamples,
222
-            sessionStart: session.startedAt,
223
-            sessionEnd: sessionEnd
224
-        )
225 128
     }
226 129
 
227 130
     // MARK: - Computed Properties
@@ -375,28 +278,6 @@ struct MeterChargeRecordContentView: View {
375 278
         }
376 279
     }
377 280
 
378
-    private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
379
-        sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session)
380
-    }
381
-
382
-    private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
383
-        if usesChargeRecordBuffer(for: session) {
384
-            return nil
385
-        }
386
-        return session.effectiveTimeRange
387
-    }
388
-
389
-    private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
390
-        guard usesChargeRecordBuffer(for: session) else {
391
-            return (nil, nil)
392
-        }
393
-        return (session.trimStart, session.trimEnd)
394
-    }
395
-
396
-    private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
397
-        session.status.isOpen && session.meterMACAddress == meterMACAddress
398
-    }
399
-
400 281
     private var showsWirelessChargerSection: Bool {
401 282
         let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
402 283
         return transportMode == .wireless
@@ -667,346 +548,6 @@ struct MeterChargeRecordContentView: View {
667 548
         }
668 549
     }
669 550
 
670
-    // MARK: - Charging Monitor Card
671
-
672
-    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
673
-        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
674
-        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
675
-        let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
676
-        let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
677
-            for: openChargeSession,
678
-            effectiveEnergyWhOverride: displayedEnergyWh
679
-        )
680
-
681
-        return VStack(alignment: .leading, spacing: 14) {
682
-            // Header
683
-            HStack {
684
-                if let device = selectedChargedDevice {
685
-                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
686
-                        .font(.headline)
687
-                } else {
688
-                    Text("Charging Monitor").font(.headline)
689
-                }
690
-                Spacer()
691
-                Text(openChargeSession.status.title)
692
-                    .font(.caption.weight(.bold))
693
-                    .foregroundColor(headerStatusColor)
694
-                    .padding(.horizontal, 8)
695
-                    .padding(.vertical, 4)
696
-                    .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
697
-            }
698
-
699
-            // Orphaned session warning — device was deleted from library
700
-            if selectedChargedDevice == nil {
701
-                VStack(alignment: .leading, spacing: 8) {
702
-                    Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
703
-                        .font(.subheadline.weight(.semibold))
704
-                        .foregroundColor(.orange)
705
-                    Text("The device associated with this session no longer exists. Stop the session to close it.")
706
-                        .font(.caption)
707
-                        .foregroundColor(.secondary)
708
-                    Button("Terminate Session") {
709
-                        _ = appData.stopChargeSession(
710
-                            sessionID: openChargeSession.id,
711
-                            finalBatteryPercent: nil
712
-                        )
713
-                    }
714
-                    .frame(maxWidth: .infinity)
715
-                    .padding(.vertical, 9)
716
-                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
717
-                    .buttonStyle(.plain)
718
-                }
719
-                .padding(14)
720
-                .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
721
-            }
722
-
723
-            // Battery prediction gauge
724
-            if let batteryPrediction {
725
-                batteryGaugeSection(
726
-                    prediction: batteryPrediction,
727
-                    session: openChargeSession,
728
-                    displayedEnergyWh: displayedEnergyWh
729
-                )
730
-            }
731
-
732
-            // Metrics grid
733
-            sessionMetricsGrid(
734
-                for: openChargeSession,
735
-                displayedEnergyWh: displayedEnergyWh,
736
-                hasPrediction: batteryPrediction != nil
737
-            )
738
-
739
-            if openChargeSession.stopThresholdAmps > 0 {
740
-                Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
741
-                    .font(.caption)
742
-                    .foregroundColor(.secondary)
743
-            }
744
-
745
-            if let sessionWarning = sessionWarning(for: openChargeSession) {
746
-                Label(sessionWarning, systemImage: "exclamationmark.triangle")
747
-                    .font(.caption)
748
-                    .foregroundColor(.orange)
749
-            }
750
-
751
-            if openChargeSession.isPaused {
752
-                Label(
753
-                    "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
754
-                    systemImage: "pause.circle"
755
-                )
756
-                .font(.caption)
757
-                .foregroundColor(.secondary)
758
-            }
759
-
760
-            if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
761
-                completionConfirmationCard(openChargeSession)
762
-            }
763
-
764
-            BatteryCheckpointSectionView(
765
-                sessionID: openChargeSession.id,
766
-                checkpoints: openChargeSession.checkpoints,
767
-                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
768
-                canAddCheckpoint: canAddCheckpoint,
769
-                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
770
-                effectiveEnergyWhOverride: displayedEnergyWh,
771
-                measuredChargeAhOverride: displayedChargeAh,
772
-                onDelete: { checkpoint in
773
-                    pendingCheckpointDeletion = checkpoint
774
-                }
775
-            )
776
-
777
-            targetSectionView(
778
-                for: openChargeSession,
779
-                predictedPercent: batteryPrediction?.predictedPercent
780
-            )
781
-
782
-            if showingStopConfirm {
783
-                stopConfirmPanel(
784
-                    for: openChargeSession,
785
-                    displayedEnergyWh: displayedEnergyWh,
786
-                    displayedChargeAh: displayedChargeAh
787
-                )
788
-            } else {
789
-                HStack(spacing: 10) {
790
-                    if openChargeSession.status == .active {
791
-                        Button("Pause") {
792
-                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
793
-                        }
794
-                        .frame(maxWidth: .infinity)
795
-                        .padding(.vertical, 10)
796
-                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
797
-                        .buttonStyle(.plain)
798
-                    } else if openChargeSession.status == .paused {
799
-                        Button("Resume") {
800
-                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
801
-                        }
802
-                        .frame(maxWidth: .infinity)
803
-                        .padding(.vertical, 10)
804
-                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
805
-                        .buttonStyle(.plain)
806
-                    }
807
-
808
-                    Button("Terminate Session") {
809
-                        finalCheckpointMode = .skip
810
-                        finalCheckpointText = ""
811
-                        showingStopConfirm = true
812
-                    }
813
-                    .frame(maxWidth: .infinity)
814
-                    .padding(.vertical, 10)
815
-                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
816
-                    .buttonStyle(.plain)
817
-                }
818
-            }
819
-        }
820
-        .padding(18)
821
-        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
822
-    }
823
-
824
-    // MARK: - Battery Gauge Section
825
-
826
-    private func batteryGaugeSection(
827
-        prediction: BatteryLevelPrediction,
828
-        session: ChargeSessionSummary,
829
-        displayedEnergyWh: Double
830
-    ) -> some View {
831
-        let percent = prediction.predictedPercent
832
-        let color = batteryColor(for: percent)
833
-        let duration = displayedSessionDuration(for: session)
834
-        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
835
-            ? displayedEnergyWh / duration
836
-            : nil
837
-
838
-        let etaToFull: String? = {
839
-            guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
840
-            let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
841
-            let seconds = remaining / rate
842
-            return seconds > 120 ? formatETA(seconds) : nil
843
-        }()
844
-
845
-        let etaToTarget: String? = {
846
-            guard let target = session.targetBatteryPercent, target > percent + 1,
847
-                  let rate = rateWhPerSec, rate > 0.0001 else { return nil }
848
-            let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
849
-            let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
850
-            let seconds = remaining / rate
851
-            return seconds > 120 ? formatETA(seconds) : nil
852
-        }()
853
-
854
-        return VStack(spacing: 10) {
855
-            HStack(alignment: .lastTextBaseline, spacing: 8) {
856
-                HStack(alignment: .lastTextBaseline, spacing: 3) {
857
-                    Text("\(Int(percent.rounded()))")
858
-                        .font(.system(size: 52, weight: .bold, design: .rounded))
859
-                        .foregroundColor(color)
860
-                        .monospacedDigit()
861
-                    Text("%")
862
-                        .font(.title2.weight(.semibold))
863
-                        .foregroundColor(color.opacity(0.8))
864
-                }
865
-                Spacer()
866
-                VStack(alignment: .trailing, spacing: 2) {
867
-                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
868
-                        .font(.callout.weight(.bold))
869
-                        .foregroundColor(.orange)
870
-                        .monospacedDigit()
871
-                    Text("est. capacity")
872
-                        .font(.caption2)
873
-                        .foregroundColor(.secondary)
874
-                }
875
-            }
876
-
877
-            batteryProgressBar(
878
-                percent: percent,
879
-                startPercent: session.startBatteryPercent,
880
-                targetPercent: session.targetBatteryPercent
881
-            )
882
-
883
-            HStack(spacing: 14) {
884
-                if let etaToFull {
885
-                    VStack(alignment: .leading, spacing: 1) {
886
-                        HStack(spacing: 4) {
887
-                            Image(systemName: "clock.fill")
888
-                                .font(.caption)
889
-                                .foregroundColor(.green)
890
-                            Text(etaToFull)
891
-                                .font(.caption.weight(.bold))
892
-                        }
893
-                        Text("to full")
894
-                            .font(.caption2)
895
-                            .foregroundColor(.secondary)
896
-                    }
897
-                }
898
-                if let etaToTarget, let target = session.targetBatteryPercent {
899
-                    VStack(alignment: .leading, spacing: 1) {
900
-                        HStack(spacing: 4) {
901
-                            Image(systemName: "bell.badge.fill")
902
-                                .font(.caption)
903
-                                .foregroundColor(.indigo)
904
-                            Text(etaToTarget)
905
-                                .font(.caption.weight(.bold))
906
-                        }
907
-                        Text("to \(Int(target.rounded()))%")
908
-                            .font(.caption2)
909
-                            .foregroundColor(.secondary)
910
-                    }
911
-                }
912
-                Spacer()
913
-                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
914
-                    .font(.caption2)
915
-                    .foregroundColor(.secondary)
916
-                    .multilineTextAlignment(.trailing)
917
-            }
918
-        }
919
-        .padding(14)
920
-        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
921
-    }
922
-
923
-    private func batteryProgressBar(
924
-        percent: Double,
925
-        startPercent: Double?,
926
-        targetPercent: Double?
927
-    ) -> some View {
928
-        let color = batteryColor(for: percent)
929
-        return GeometryReader { geo in
930
-            let width = geo.size.width
931
-            ZStack(alignment: .leading) {
932
-                Capsule()
933
-                    .fill(Color.primary.opacity(0.10))
934
-                Rectangle()
935
-                    .fill(
936
-                        LinearGradient(
937
-                            colors: [color.opacity(0.6), color],
938
-                            startPoint: .leading,
939
-                            endPoint: .trailing
940
-                        )
941
-                    )
942
-                    .frame(width: max(width * CGFloat(percent / 100), 4))
943
-                    .animation(.easeInOut(duration: 0.4), value: percent)
944
-                if let start = startPercent, start > 2, start < 98 {
945
-                    Rectangle()
946
-                        .fill(Color.white.opacity(0.55))
947
-                        .frame(width: 2, height: 20)
948
-                        .offset(x: width * CGFloat(start / 100) - 1)
949
-                }
950
-                if let target = targetPercent {
951
-                    Rectangle()
952
-                        .fill(Color.indigo.opacity(0.9))
953
-                        .frame(width: 2.5, height: 20)
954
-                        .offset(x: width * CGFloat(target / 100) - 1.25)
955
-                }
956
-            }
957
-            .clipShape(Capsule())
958
-        }
959
-        .frame(height: 20)
960
-    }
961
-
962
-    private func batteryColor(for percent: Double) -> Color {
963
-        if percent >= 75 { return .green }
964
-        if percent >= 35 { return .orange }
965
-        return .red
966
-    }
967
-
968
-    private func formatETA(_ seconds: TimeInterval) -> String {
969
-        let totalMinutes = Int(seconds / 60)
970
-        if totalMinutes < 60 { return "\(totalMinutes)m" }
971
-        let hours = totalMinutes / 60
972
-        let minutes = totalMinutes % 60
973
-        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
974
-    }
975
-
976
-    // MARK: - Session Metrics Grid
977
-
978
-    private func sessionMetricsGrid(
979
-        for session: ChargeSessionSummary,
980
-        displayedEnergyWh: Double,
981
-        hasPrediction: Bool
982
-    ) -> some View {
983
-        let displayedDuration = displayedSessionDuration(for: session)
984
-        let capacityFallback: Double? = hasPrediction ? nil : (
985
-            session.capacityEstimateWh
986
-                ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
987
-                ?? selectedChargedDevice?.estimatedBatteryCapacityWh
988
-        )
989
-        let columns = [GridItem(.flexible()), GridItem(.flexible())]
990
-
991
-        return LazyVGrid(columns: columns, spacing: 8) {
992
-            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
993
-            metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal)
994
-
995
-            if shouldShowChargingTransport(for: session) {
996
-                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
997
-            }
998
-            if shouldShowChargingState(for: session) {
999
-                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
1000
-            }
1001
-
1002
-            metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
1003
-
1004
-            if let capacity = capacityFallback {
1005
-                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
1006
-            }
1007
-        }
1008
-    }
1009
-
1010 551
     private func metricCell(label: String, value: String, tint: Color) -> some View {
1011 552
         VStack(alignment: .leading, spacing: 3) {
1012 553
             Text(label)
@@ -1024,467 +565,6 @@ struct MeterChargeRecordContentView: View {
1024 565
         .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
1025 566
     }
1026 567
 
1027
-    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
1028
-        VStack(alignment: .leading, spacing: 10) {
1029
-            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
1030
-                .font(.subheadline.weight(.semibold))
1031
-
1032
-            if let contradictionPercent = openChargeSession.completionContradictionPercent {
1033
-                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
1034
-                    .font(.caption)
1035
-                    .foregroundColor(.secondary)
1036
-            } else {
1037
-                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
1038
-                    .font(.caption)
1039
-                    .foregroundColor(.secondary)
1040
-            }
1041
-
1042
-            HStack(spacing: 10) {
1043
-                Button("Finish") {
1044
-                    finalCheckpointMode = .skip
1045
-                    finalCheckpointText = ""
1046
-                    showingStopConfirm = true
1047
-                }
1048
-                .frame(maxWidth: .infinity)
1049
-                .padding(.vertical, 9)
1050
-                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1051
-                .buttonStyle(.plain)
1052
-
1053
-                Button("Keep Monitoring") {
1054
-                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
1055
-                }
1056
-                .frame(maxWidth: .infinity)
1057
-                .padding(.vertical, 9)
1058
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1059
-                .buttonStyle(.plain)
1060
-            }
1061
-        }
1062
-        .padding(14)
1063
-        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
1064
-    }
1065
-
1066
-    // MARK: - Target Section
1067
-
1068
-    private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
1069
-        let draftBelowPrediction: Bool = {
1070
-            guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
1071
-            return draft <= predicted
1072
-        }()
1073
-        let savedBelowPrediction: Bool = {
1074
-            guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
1075
-            return saved <= predicted
1076
-        }()
1077
-
1078
-        return HStack(alignment: .center, spacing: 8) {
1079
-            Image(systemName: "bell.badge")
1080
-                .foregroundColor(.indigo)
1081
-                .font(.subheadline)
1082
-
1083
-            Text("Notify at")
1084
-                .font(.subheadline.weight(.semibold))
1085
-
1086
-            Spacer(minLength: 8)
1087
-
1088
-            if showingInlineTargetEditor {
1089
-                Button {
1090
-                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1091
-                    let next = max(current - 1, 1)
1092
-                    draftTargetText = next.format(decimalDigits: 0)
1093
-                } label: {
1094
-                    Image(systemName: "minus.circle")
1095
-                        .font(.title3)
1096
-                }
1097
-                .buttonStyle(.plain)
1098
-
1099
-                TextField("—", text: $draftTargetText)
1100
-                    .keyboardType(.decimalPad)
1101
-                    .textFieldStyle(.roundedBorder)
1102
-                    .frame(width: 48)
1103
-                    .multilineTextAlignment(.center)
1104
-                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
1105
-
1106
-                Text("%")
1107
-                    .font(.subheadline)
1108
-                    .foregroundColor(.secondary)
1109
-
1110
-                if draftBelowPrediction {
1111
-                    Button {} label: {
1112
-                        Image(systemName: "exclamationmark.triangle.fill")
1113
-                            .font(.body.weight(.semibold))
1114
-                            .foregroundColor(.orange)
1115
-                    }
1116
-                    .buttonStyle(.plain)
1117
-                    .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
1118
-                }
1119
-
1120
-                Button {
1121
-                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1122
-                    let next = min(current + 1, 100)
1123
-                    draftTargetText = next.format(decimalDigits: 0)
1124
-                } label: {
1125
-                    Image(systemName: "plus.circle")
1126
-                        .font(.title3)
1127
-                }
1128
-                .buttonStyle(.plain)
1129
-
1130
-                Button {
1131
-                    if let value = parsedDraftTarget {
1132
-                        _ = appData.setTargetBatteryPercent(value, for: session.id)
1133
-                    }
1134
-                    showingInlineTargetEditor = false
1135
-                } label: {
1136
-                    Image(systemName: "checkmark.circle.fill")
1137
-                        .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1138
-                        .font(.title3)
1139
-                }
1140
-                .buttonStyle(.plain)
1141
-                .disabled(parsedDraftTarget == nil)
1142
-
1143
-                Button {
1144
-                    showingInlineTargetEditor = false
1145
-                    draftTargetText = ""
1146
-                } label: {
1147
-                    Image(systemName: "xmark.circle")
1148
-                        .foregroundColor(.secondary)
1149
-                        .font(.title3)
1150
-                }
1151
-                .buttonStyle(.plain)
1152
-
1153
-            } else {
1154
-                if let targetPercent = session.targetBatteryPercent {
1155
-                    Text("\(targetPercent.format(decimalDigits: 0))%")
1156
-                        .font(.subheadline.weight(.semibold))
1157
-                        .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1158
-
1159
-                    if savedBelowPrediction {
1160
-                        Button {} label: {
1161
-                            Image(systemName: "exclamationmark.triangle.fill")
1162
-                                .font(.callout.weight(.semibold))
1163
-                                .foregroundColor(.orange)
1164
-                        }
1165
-                        .buttonStyle(.plain)
1166
-                        .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
1167
-                    }
1168
-
1169
-                    Button {
1170
-                        _ = appData.setTargetBatteryPercent(nil, for: session.id)
1171
-                    } label: {
1172
-                        Image(systemName: "xmark.circle.fill")
1173
-                            .foregroundColor(.secondary)
1174
-                            .font(.callout)
1175
-                    }
1176
-                    .buttonStyle(.plain)
1177
-                    .help("Remove alert")
1178
-                }
1179
-
1180
-                Button {
1181
-                    draftTargetText = session.targetBatteryPercent.map {
1182
-                        $0.format(decimalDigits: 0)
1183
-                    } ?? "80"
1184
-                    showingInlineTargetEditor = true
1185
-                } label: {
1186
-                    Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1187
-                        .font(.caption.weight(.semibold))
1188
-                        .frame(width: 30, height: 30)
1189
-                        .contentShape(Rectangle())
1190
-                }
1191
-                .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1192
-                .buttonStyle(.plain)
1193
-                .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1194
-            }
1195
-        }
1196
-    }
1197
-
1198
-    private var parsedDraftTarget: Double? {
1199
-        let normalized = draftTargetText
1200
-            .trimmingCharacters(in: .whitespacesAndNewlines)
1201
-            .replacingOccurrences(of: ",", with: ".")
1202
-        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1203
-        return value
1204
-    }
1205
-
1206
-    private func stopConfirmPanel(
1207
-        for session: ChargeSessionSummary,
1208
-        displayedEnergyWh: Double,
1209
-        displayedChargeAh: Double
1210
-    ) -> some View {
1211
-        let canSave = hasSavableChargeData(
1212
-            for: session,
1213
-            displayedEnergyWh: displayedEnergyWh,
1214
-            displayedChargeAh: displayedChargeAh
1215
-        )
1216
-        let hasInvalidCustomCheckpoint = finalCheckpointMode == .custom
1217
-            && finalCheckpointText.isEmpty == false
1218
-            && parsedFinalCheckpoint == nil
1219
-
1220
-        return VStack(alignment: .leading, spacing: 12) {
1221
-            Text("Final Checkpoint (optional)")
1222
-                .font(.subheadline.weight(.semibold))
1223
-
1224
-            HStack(spacing: 8) {
1225
-                ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1226
-                    Button {
1227
-                        finalCheckpointMode = mode
1228
-                        if mode != .custom { finalCheckpointText = "" }
1229
-                    } label: {
1230
-                        VStack(spacing: 5) {
1231
-                            Image(systemName: mode.icon)
1232
-                                .font(.title3)
1233
-                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1234
-                            Text(mode.label)
1235
-                                .font(.caption.weight(.semibold))
1236
-                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1237
-                        }
1238
-                        .frame(maxWidth: .infinity)
1239
-                        .padding(.vertical, 10)
1240
-                        .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1241
-                        .meterCard(
1242
-                            tint: finalCheckpointMode == mode ? .primary : .secondary,
1243
-                            fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1244
-                            strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1245
-                            cornerRadius: 12
1246
-                        )
1247
-                    }
1248
-                    .buttonStyle(.plain)
1249
-                }
1250
-            }
1251
-
1252
-            if finalCheckpointMode == .custom {
1253
-                HStack(spacing: 8) {
1254
-                    Button { adjustFinalCheckpoint(by: -1) } label: {
1255
-                        Image(systemName: "minus.circle").font(.title3)
1256
-                    }
1257
-                    .buttonStyle(.plain)
1258
-
1259
-                    TextField("—", text: $finalCheckpointText)
1260
-                        .keyboardType(.decimalPad)
1261
-                        .textFieldStyle(.roundedBorder)
1262
-                        .frame(width: 56)
1263
-                        .multilineTextAlignment(.center)
1264
-
1265
-                    Text("%").foregroundColor(.secondary)
1266
-
1267
-                    Button { adjustFinalCheckpoint(by: 1) } label: {
1268
-                        Image(systemName: "plus.circle").font(.title3)
1269
-                    }
1270
-                    .buttonStyle(.plain)
1271
-
1272
-                    Spacer()
1273
-                }
1274
-            }
1275
-
1276
-            if !canSave {
1277
-                Label("This session has no charging data to save. Discard it instead.", systemImage: "exclamationmark.triangle.fill")
1278
-                    .font(.caption)
1279
-                    .foregroundColor(.red)
1280
-                    .fixedSize(horizontal: false, vertical: true)
1281
-            } else if hasInvalidCustomCheckpoint {
1282
-                Label("Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint.", systemImage: "exclamationmark.triangle.fill")
1283
-                    .font(.caption)
1284
-                    .foregroundColor(.orange)
1285
-                    .fixedSize(horizontal: false, vertical: true)
1286
-            }
1287
-
1288
-            HStack(spacing: 8) {
1289
-                Button("Discard") {
1290
-                    _ = appData.deleteChargeSession(sessionID: session.id)
1291
-                    showingStopConfirm = false
1292
-                    finalCheckpointText = ""
1293
-                    finalCheckpointMode = .full
1294
-                }
1295
-                .frame(maxWidth: .infinity)
1296
-                .padding(.vertical, 9)
1297
-                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1298
-                .buttonStyle(.plain)
1299
-
1300
-                Button("Save") {
1301
-                    _ = appData.stopChargeSession(
1302
-                        sessionID: session.id,
1303
-                        finalBatteryPercent: resolvedFinalCheckpoint,
1304
-                        from: usbMeter
1305
-                    )
1306
-                    showingStopConfirm = false
1307
-                    finalCheckpointText = ""
1308
-                    finalCheckpointMode = .full
1309
-                }
1310
-                .frame(maxWidth: .infinity)
1311
-                .padding(.vertical, 9)
1312
-                .meterCard(
1313
-                    tint: canSave ? .green : .red,
1314
-                    fillOpacity: canSave ? 0.16 : 0.08,
1315
-                    strokeOpacity: canSave ? 0.22 : 0.28,
1316
-                    cornerRadius: 14
1317
-                )
1318
-                .buttonStyle(.plain)
1319
-                .disabled(!canSave)
1320
-                .opacity(canSave ? 1 : 0.52)
1321
-
1322
-                Button("Cancel") {
1323
-                    showingStopConfirm = false
1324
-                    finalCheckpointText = ""
1325
-                    finalCheckpointMode = .full
1326
-                }
1327
-                .frame(maxWidth: .infinity)
1328
-                .padding(.vertical, 9)
1329
-                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1330
-                .buttonStyle(.plain)
1331
-            }
1332
-        }
1333
-        .padding(14)
1334
-        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1335
-    }
1336
-
1337
-    private var parsedFinalCheckpoint: Double? {
1338
-        let normalized = finalCheckpointText
1339
-            .trimmingCharacters(in: .whitespacesAndNewlines)
1340
-            .replacingOccurrences(of: ",", with: ".")
1341
-        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1342
-        return value
1343
-    }
1344
-
1345
-    private var resolvedFinalCheckpoint: Double? {
1346
-        switch finalCheckpointMode {
1347
-        case .full:   return 100.0
1348
-        case .skip:   return nil
1349
-        case .custom: return parsedFinalCheckpoint
1350
-        }
1351
-    }
1352
-
1353
-    private func adjustFinalCheckpoint(by delta: Double) {
1354
-        let current = parsedFinalCheckpoint ?? 0
1355
-        let next = min(max(current + delta, 0), 100)
1356
-        finalCheckpointText = next.format(decimalDigits: 0)
1357
-    }
1358
-
1359
-    private func hasSavableChargeData(
1360
-        for session: ChargeSessionSummary,
1361
-        displayedEnergyWh: Double,
1362
-        displayedChargeAh: Double
1363
-    ) -> Bool {
1364
-        session.hasSavableChargeData
1365
-            || displayedEnergyWh > 0
1366
-            || displayedChargeAh > 0
1367
-    }
1368
-
1369
-    // MARK: - Trim Detection Banner
1370
-
1371
-    @ViewBuilder
1372
-    private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
1373
-        if let window = detectedTrimWindow {
1374
-            HStack(spacing: 12) {
1375
-                Image(systemName: "scissors.circle.fill")
1376
-                    .font(.title3)
1377
-                    .foregroundColor(.blue)
1378
-
1379
-                VStack(alignment: .leading, spacing: 2) {
1380
-                    Text("Charging ended early")
1381
-                        .font(.subheadline.weight(.semibold))
1382
-                    Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
1383
-                        .font(.caption)
1384
-                        .foregroundColor(.secondary)
1385
-                        .fixedSize(horizontal: false, vertical: true)
1386
-                }
1387
-
1388
-                Spacer(minLength: 0)
1389
-
1390
-                VStack(spacing: 6) {
1391
-                    Button("Apply") {
1392
-                        _ = appData.setSessionTrim(
1393
-                            sessionID: session.id,
1394
-                            start: window.start,
1395
-                            end: window.end
1396
-                        )
1397
-                        trimBannerDismissedForSessionID = session.id
1398
-                    }
1399
-                    .font(.caption.weight(.semibold))
1400
-                    .buttonStyle(.borderedProminent)
1401
-                    .controlSize(.small)
1402
-                    .tint(.blue)
1403
-
1404
-                    Button {
1405
-                        trimBannerDismissedForSessionID = session.id
1406
-                    } label: {
1407
-                        Image(systemName: "xmark")
1408
-                            .font(.caption2.weight(.semibold))
1409
-                            .foregroundColor(.secondary)
1410
-                    }
1411
-                    .buttonStyle(.plain)
1412
-                }
1413
-            }
1414
-            .padding(14)
1415
-            .background(
1416
-                RoundedRectangle(cornerRadius: 14)
1417
-                    .fill(Color.blue.opacity(0.10))
1418
-                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1419
-            )
1420
-            .transition(.opacity.combined(with: .move(edge: .top)))
1421
-        }
1422
-    }
1423
-
1424
-    private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
1425
-        let hasRangeSelector = session.aggregatedSamples.isEmpty == false
1426
-
1427
-        return VStack(alignment: .leading, spacing: 12) {
1428
-            HStack(spacing: 8) {
1429
-                Image(systemName: "chart.xyaxis.line")
1430
-                    .foregroundColor(.blue)
1431
-                Text("Session Chart")
1432
-                    .font(.headline)
1433
-                ContextInfoButton(
1434
-                    title: "Session Chart",
1435
-                    message: usesChargeRecordBuffer(for: session)
1436
-                        ? "This chart combines the persisted session curve with current live data from this meter."
1437
-                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1438
-                )
1439
-                Spacer(minLength: 0)
1440
-            }
1441
-
1442
-            MeasurementChartView(
1443
-                timeRange: timeRange,
1444
-                timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1445
-                timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1446
-                showsRangeSelector: hasRangeSelector,
1447
-                rebasesEnergyToVisibleRangeStart: true,
1448
-                extendsTimelineToPresent: false,
1449
-                showsTemperatureSeries: false,
1450
-                rangeSelectorConfiguration: hasRangeSelector
1451
-                    ? MeasurementChartRangeSelectorConfiguration(
1452
-                        keepAction: MeasurementChartSelectionAction(
1453
-                            title: "Keep Selection",
1454
-                            shortTitle: "Keep",
1455
-                            systemName: "scissors",
1456
-                            tone: .destructive,
1457
-                            handler: { range in
1458
-                                _ = appData.setSessionTrim(
1459
-                                    sessionID: session.id,
1460
-                                    start: range.lowerBound,
1461
-                                    end: range.upperBound
1462
-                                )
1463
-                                trimBannerDismissedForSessionID = session.id
1464
-                            }
1465
-                        ),
1466
-                        removeAction: nil,
1467
-                        resetAction: MeasurementChartResetAction(
1468
-                            title: "Reset Trim",
1469
-                            shortTitle: "Reset",
1470
-                            systemName: "arrow.counterclockwise",
1471
-                            tone: .reversible,
1472
-                            confirmationTitle: "Reset session trim?",
1473
-                            confirmationButtonTitle: "Reset trim",
1474
-                            handler: {
1475
-                                _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1476
-                            }
1477
-                        )
1478
-                    )
1479
-                    : nil
1480
-            )
1481
-            .environmentObject(usbMeter.chargeRecordMeasurements)
1482
-            .frame(maxWidth: .infinity, alignment: .topLeading)
1483
-        }
1484
-        .padding(18)
1485
-        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1486
-    }
1487
-
1488 568
     private var meterTotalsCard: some View {
1489 569
         return VStack(alignment: .leading, spacing: 12) {
1490 570
             HStack(spacing: 8) {
@@ -1552,102 +632,6 @@ struct MeterChargeRecordContentView: View {
1552 632
         .padding(.vertical, 11)
1553 633
     }
1554 634
 
1555
-    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1556
-        if session.autoStopEnabled == false {
1557
-            return "Manual"
1558
-        }
1559
-        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1560
-            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1561
-        }
1562
-        if session.stopThresholdAmps > 0 {
1563
-            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1564
-        }
1565
-        return "Learning"
1566
-    }
1567
-
1568
-    private func sessionMetricRows(
1569
-        for session: ChargeSessionSummary,
1570
-        displayedEnergyWh: Double
1571
-    ) -> [SessionMetricRow] {
1572
-        var rows: [SessionMetricRow] = []
1573
-
1574
-        if shouldShowChargingTransport(for: session) {
1575
-            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1576
-        }
1577
-
1578
-        if shouldShowChargingState(for: session) {
1579
-            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1580
-        }
1581
-
1582
-        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
1583
-        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
1584
-        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1585
-        return rows
1586
-    }
1587
-
1588
-    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
1589
-        guard let selectedChargedDevice else { return true }
1590
-        return selectedChargedDevice.supportedChargingModes.count > 1
1591
-            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1592
-    }
1593
-
1594
-    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
1595
-        guard let selectedChargedDevice else { return true }
1596
-        return selectedChargedDevice.supportedChargingStateModes.count > 1
1597
-            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1598
-    }
1599
-
1600
-    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1601
-        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1602
-        guard session.isTrimmed == false else { return storedEnergyWh }
1603
-        guard session.status.isOpen else { return storedEnergyWh }
1604
-        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
1605
-        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1606
-            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1607
-        }
1608
-        return storedEnergyWh
1609
-    }
1610
-
1611
-    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1612
-        let storedChargeAh = session.measuredChargeAh
1613
-        guard session.isTrimmed == false else { return storedChargeAh }
1614
-        guard session.status.isOpen else { return storedChargeAh }
1615
-        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
1616
-        if let baselineChargeAh = session.meterChargeBaselineAh {
1617
-            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1618
-        }
1619
-        return storedChargeAh
1620
-    }
1621
-
1622
-    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1623
-        let storedDuration = max(session.effectiveDuration, 0)
1624
-        guard session.isTrimmed == false else { return storedDuration }
1625
-        guard session.status.isOpen else { return storedDuration }
1626
-        guard session.meterMACAddress == meterMACAddress else { return storedDuration }
1627
-        return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
1628
-    }
1629
-
1630
-    private func formatDuration(_ duration: TimeInterval) -> String {
1631
-        let totalSeconds = Int(duration.rounded(.down))
1632
-        let hours = totalSeconds / 3600
1633
-        let minutes = (totalSeconds % 3600) / 60
1634
-        let seconds = totalSeconds % 60
1635
-        if hours > 0 {
1636
-            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1637
-        }
1638
-        return String(format: "%02d:%02d", minutes, seconds)
1639
-    }
1640
-
1641
-    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1642
-        guard session.chargingTransportMode == .wireless,
1643
-              let chargerID = session.chargerID,
1644
-              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1645
-            return nil
1646
-        }
1647
-        guard charger.chargerIdleCurrentAmps == nil else { return nil }
1648
-        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1649
-    }
1650
-
1651 635
     private func startSession() {
1652 636
         guard let selectedChargedDevice,
1653 637
               let chargingTransportMode = selectedDraftTransportMode,
+0 -378
USB Meter/Views/Meter/Tabs/ChargeRecord/SessionTrimEditorView.swift
@@ -1,378 +0,0 @@
1
-//
2
-//  SessionTrimEditorView.swift
3
-//  USB Meter
4
-//
5
-
6
-import SwiftUI
7
-
8
-struct SessionTrimEditorView: View {
9
-
10
-    let session: ChargeSessionSummary
11
-    let liveTimeRange: ClosedRange<Date>?
12
-    let onApply: (Date?, Date?) -> Void
13
-    let onDismiss: () -> Void
14
-
15
-    @State private var trimStart: Date
16
-    @State private var trimEnd: Date
17
-
18
-    private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt }
19
-    private var fullEnd: Date   { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) }
20
-    private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) }
21
-
22
-    private var startFraction: Double {
23
-        trimStart.timeIntervalSince(fullStart) / sessionDuration
24
-    }
25
-    private var endFraction: Double {
26
-        trimEnd.timeIntervalSince(fullStart) / sessionDuration
27
-    }
28
-
29
-    // Energy preview from cumulative sample values
30
-    private var previewEnergyWh: Double {
31
-        let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp }
32
-        let baseline = sorted.last { $0.timestamp <= trimStart }
33
-        guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 }
34
-        return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0)
35
-    }
36
-
37
-    private var trimmedDuration: TimeInterval {
38
-        max(trimEnd.timeIntervalSince(trimStart), 0)
39
-    }
40
-
41
-    private var checkpointsToRemove: [ChargeCheckpointSummary] {
42
-        session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd }
43
-    }
44
-
45
-    private var isModified: Bool {
46
-        trimStart != (session.trimStart ?? fullStart) ||
47
-        trimEnd   != (session.trimEnd   ?? fullEnd)
48
-    }
49
-
50
-    init(
51
-        session: ChargeSessionSummary,
52
-        detectedWindow: ChargingWindowDetector.DetectedWindow? = nil,
53
-        liveTimeRange: ClosedRange<Date>? = nil,
54
-        onApply: @escaping (Date?, Date?) -> Void,
55
-        onDismiss: @escaping () -> Void
56
-    ) {
57
-        self.session   = session
58
-        self.liveTimeRange = liveTimeRange
59
-        self.onApply   = onApply
60
-        self.onDismiss = onDismiss
61
-
62
-        let fullStart = liveTimeRange?.lowerBound ?? session.startedAt
63
-        let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt)
64
-        let start = session.trimStart
65
-            ?? detectedWindow?.start
66
-            ?? fullStart
67
-        let end = session.trimEnd
68
-            ?? detectedWindow?.end
69
-            ?? fullEnd
70
-
71
-        _trimStart = State(initialValue: start)
72
-        _trimEnd   = State(initialValue: end)
73
-    }
74
-
75
-    var body: some View {
76
-        VStack(spacing: 0) {
77
-            header
78
-            ScrollView {
79
-                VStack(spacing: 16) {
80
-                    chartWithHandles
81
-                    rangeControls
82
-                    previewMetrics
83
-                    if !checkpointsToRemove.isEmpty {
84
-                        checkpointWarning
85
-                    }
86
-                }
87
-                .padding(16)
88
-            }
89
-            applyBar
90
-        }
91
-        .background(Color(.systemGroupedBackground).ignoresSafeArea())
92
-    }
93
-
94
-    // MARK: - Header
95
-
96
-    private var header: some View {
97
-        HStack {
98
-            Button("Cancel", action: onDismiss)
99
-                .foregroundColor(.secondary)
100
-            Spacer()
101
-            Text("Trim Session")
102
-                .font(.headline)
103
-            Spacer()
104
-            Button("Reset") {
105
-                withAnimation(.spring(response: 0.3)) {
106
-                    trimStart = fullStart
107
-                    trimEnd   = fullEnd
108
-                }
109
-            }
110
-            .foregroundColor(.orange)
111
-            .disabled(trimStart == fullStart && trimEnd == fullEnd)
112
-        }
113
-        .padding(.horizontal, 18)
114
-        .padding(.vertical, 14)
115
-        .background(.regularMaterial)
116
-    }
117
-
118
-    // MARK: - Chart with trim overlay
119
-
120
-    private var chartWithHandles: some View {
121
-        VStack(alignment: .leading, spacing: 10) {
122
-            HStack(spacing: 6) {
123
-                Image(systemName: "scissors")
124
-                    .foregroundColor(.blue)
125
-                Text("Session Window")
126
-                    .font(.headline)
127
-            }
128
-
129
-            GeometryReader { geo in
130
-                let chartW = geo.size.width
131
-                ZStack(alignment: .topLeading) {
132
-                    // Background chart — full session
133
-                    MeasurementChartView(
134
-                        sizing: .provided(size: geo.size, compact: true),
135
-                        timeRange: fullStart...fullEnd,
136
-                        showsRangeSelector: false,
137
-                        rebasesEnergyToVisibleRangeStart: false,
138
-                        showsTemperatureSeries: false
139
-                    )
140
-
141
-                    // Dimmed region before trimStart
142
-                    Rectangle()
143
-                        .fill(Color.black.opacity(0.35))
144
-                        .frame(width: max(startFraction * chartW, 0))
145
-                        .allowsHitTesting(false)
146
-
147
-                    // Dimmed region after trimEnd
148
-                    let endX = endFraction * chartW
149
-                    Rectangle()
150
-                        .fill(Color.black.opacity(0.35))
151
-                        .frame(width: max(chartW - endX, 0))
152
-                        .offset(x: endX)
153
-                        .allowsHitTesting(false)
154
-
155
-                    // Start handle
156
-                    trimHandle(
157
-                        color: .green,
158
-                        symbol: "arrow.right.to.line",
159
-                        xFraction: startFraction,
160
-                        chartWidth: chartW,
161
-                        onDrag: { dx in
162
-                            let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01))
163
-                            trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration)
164
-                        }
165
-                    )
166
-
167
-                    // End handle
168
-                    trimHandle(
169
-                        color: .red,
170
-                        symbol: "arrow.left.to.line",
171
-                        xFraction: endFraction,
172
-                        chartWidth: chartW,
173
-                        onDrag: { dx in
174
-                            let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01))
175
-                            trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration)
176
-                        }
177
-                    )
178
-                }
179
-                .clipped()
180
-            }
181
-            .frame(height: 260)
182
-        }
183
-        .padding(16)
184
-        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
185
-    }
186
-
187
-    @ViewBuilder
188
-    private func trimHandle(
189
-        color: Color,
190
-        symbol: String,
191
-        xFraction: Double,
192
-        chartWidth: CGFloat,
193
-        onDrag: @escaping (CGFloat) -> Void
194
-    ) -> some View {
195
-        let xPos = CGFloat(xFraction) * chartWidth
196
-
197
-        ZStack(alignment: .top) {
198
-            // Vertical line
199
-            Rectangle()
200
-                .fill(color)
201
-                .frame(width: 2)
202
-                .frame(maxHeight: .infinity)
203
-                .offset(x: xPos - 1)
204
-                .allowsHitTesting(false)
205
-
206
-            // Drag knob
207
-            Circle()
208
-                .fill(color)
209
-                .frame(width: 28, height: 28)
210
-                .overlay(
211
-                    Image(systemName: symbol)
212
-                        .font(.system(size: 11, weight: .bold))
213
-                        .foregroundColor(.white)
214
-                )
215
-                .shadow(radius: 3)
216
-                .offset(x: xPos - 14)
217
-                .gesture(
218
-                    DragGesture(minimumDistance: 0, coordinateSpace: .local)
219
-                        .onChanged { value in
220
-                            onDrag(value.translation.width)
221
-                        }
222
-                )
223
-        }
224
-    }
225
-
226
-    // MARK: - Range controls
227
-
228
-    private var rangeControls: some View {
229
-        VStack(spacing: 12) {
230
-            rangeRow(
231
-                label: "Start",
232
-                color: .green,
233
-                symbol: "arrow.right.to.line",
234
-                date: $trimStart,
235
-                sliderValue: Binding(
236
-                    get: { startFraction },
237
-                    set: { v in
238
-                        let clamped = max(0, min(v, endFraction - 0.01))
239
-                        trimStart = fullStart.addingTimeInterval(clamped * sessionDuration)
240
-                    }
241
-                )
242
-            )
243
-            rangeRow(
244
-                label: "End",
245
-                color: .red,
246
-                symbol: "arrow.left.to.line",
247
-                date: $trimEnd,
248
-                sliderValue: Binding(
249
-                    get: { endFraction },
250
-                    set: { v in
251
-                        let clamped = min(1, max(v, startFraction + 0.01))
252
-                        trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration)
253
-                    }
254
-                )
255
-            )
256
-        }
257
-        .padding(16)
258
-        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
259
-    }
260
-
261
-    private func rangeRow(
262
-        label: String,
263
-        color: Color,
264
-        symbol: String,
265
-        date: Binding<Date>,
266
-        sliderValue: Binding<Double>
267
-    ) -> some View {
268
-        VStack(spacing: 6) {
269
-            HStack {
270
-                Image(systemName: symbol)
271
-                    .foregroundColor(color)
272
-                    .frame(width: 20)
273
-                Text(label)
274
-                    .font(.subheadline.weight(.semibold))
275
-                Spacer()
276
-                Text(date.wrappedValue.format())
277
-                    .font(.caption.monospacedDigit())
278
-                    .foregroundColor(.secondary)
279
-            }
280
-            Slider(value: sliderValue, in: 0...1)
281
-                .tint(color)
282
-        }
283
-    }
284
-
285
-    // MARK: - Preview metrics
286
-
287
-    private var previewMetrics: some View {
288
-        VStack(alignment: .leading, spacing: 12) {
289
-            HStack(spacing: 6) {
290
-                Image(systemName: "waveform.path.ecg")
291
-                    .foregroundColor(.teal)
292
-                Text("Trimmed Metrics")
293
-                    .font(.headline)
294
-            }
295
-
296
-            let columns = [GridItem(.flexible()), GridItem(.flexible())]
297
-            LazyVGrid(columns: columns, spacing: 8) {
298
-                previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
299
-                previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal)
300
-            }
301
-        }
302
-        .padding(16)
303
-        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
304
-    }
305
-
306
-    private func previewCell(label: String, value: String, tint: Color) -> some View {
307
-        VStack(alignment: .leading, spacing: 4) {
308
-            Text(label)
309
-                .font(.caption)
310
-                .foregroundColor(.secondary)
311
-            Text(value)
312
-                .font(.system(.subheadline, design: .rounded).weight(.semibold))
313
-                .foregroundColor(tint)
314
-                .monospacedDigit()
315
-        }
316
-        .frame(maxWidth: .infinity, alignment: .leading)
317
-        .padding(10)
318
-        .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10)))
319
-    }
320
-
321
-    // MARK: - Checkpoint warning
322
-
323
-    private var checkpointWarning: some View {
324
-        HStack(alignment: .top, spacing: 10) {
325
-            Image(systemName: "exclamationmark.triangle.fill")
326
-                .foregroundColor(.orange)
327
-            VStack(alignment: .leading, spacing: 3) {
328
-                Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.")
329
-                    .font(.subheadline)
330
-                ForEach(checkpointsToRemove) { cp in
331
-                    Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%")
332
-                        .font(.caption)
333
-                        .foregroundColor(.secondary)
334
-                }
335
-            }
336
-        }
337
-        .padding(14)
338
-        .background(
339
-            RoundedRectangle(cornerRadius: 12)
340
-                .fill(Color.orange.opacity(0.12))
341
-                .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1))
342
-        )
343
-    }
344
-
345
-    // MARK: - Apply bar
346
-
347
-    private var applyBar: some View {
348
-        VStack(spacing: 0) {
349
-            Divider()
350
-            Button {
351
-                let newStart = trimStart == fullStart ? nil : trimStart
352
-                let newEnd   = trimEnd   == fullEnd   ? nil : trimEnd
353
-                onApply(newStart, newEnd)
354
-            } label: {
355
-                Label("Apply Trim", systemImage: "scissors")
356
-                    .font(.body.weight(.semibold))
357
-                    .frame(maxWidth: .infinity)
358
-                    .padding(.vertical, 14)
359
-            }
360
-            .buttonStyle(.borderedProminent)
361
-            .tint(.blue)
362
-            .disabled(!isModified)
363
-            .padding(16)
364
-        }
365
-        .background(.regularMaterial)
366
-    }
367
-
368
-    // MARK: - Helpers
369
-
370
-    private func formatDuration(_ duration: TimeInterval) -> String {
371
-        let totalSeconds = Int(duration.rounded(.down))
372
-        let hours = totalSeconds / 3600
373
-        let minutes = (totalSeconds % 3600) / 60
374
-        let seconds = totalSeconds % 60
375
-        if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) }
376
-        return String(format: "%02d:%02d", minutes, seconds)
377
-    }
378
-}
+70 -0
USB Meter/Views/Sidebar/SidebarList/Components/SidebarOfflineMeterCardView.swift
@@ -0,0 +1,70 @@
1
+//
2
+//  SidebarOfflineMeterCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarOfflineMeterCardView: View {
9
+    let summary: AppData.MeterSummary
10
+
11
+    var body: some View {
12
+        HStack(spacing: 14) {
13
+            Image(systemName: "sensor.tag.radiowaves.forward.fill")
14
+                .font(.system(size: 18, weight: .semibold))
15
+                .foregroundColor(.secondary)
16
+                .frame(width: 42, height: 42)
17
+                .background(
18
+                    Circle()
19
+                        .fill(Color.secondary.opacity(0.14))
20
+                )
21
+                .overlay(alignment: .bottomTrailing) {
22
+                    Circle()
23
+                        .fill(Color.secondary)
24
+                        .frame(width: 12, height: 12)
25
+                        .overlay(
26
+                            Circle()
27
+                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
28
+                        )
29
+                }
30
+
31
+            VStack(alignment: .leading, spacing: 4) {
32
+                Text(summary.displayName)
33
+                    .font(.headline)
34
+                Text(summary.modelSummary.isEmpty ? "Unknown Model" : summary.modelSummary)
35
+                    .font(.caption)
36
+                    .foregroundColor(.secondary)
37
+            }
38
+
39
+            Spacer()
40
+
41
+            VStack(alignment: .trailing, spacing: 4) {
42
+                HStack(spacing: 6) {
43
+                    Circle()
44
+                        .fill(Color.secondary)
45
+                        .frame(width: 8, height: 8)
46
+                    Text("Offline")
47
+                        .font(.caption.weight(.semibold))
48
+                        .foregroundColor(.secondary)
49
+                }
50
+                .padding(.horizontal, 10)
51
+                .padding(.vertical, 6)
52
+                .background(
53
+                    Capsule(style: .continuous)
54
+                        .fill(Color.secondary.opacity(0.12))
55
+                )
56
+                .overlay(
57
+                    Capsule(style: .continuous)
58
+                        .stroke(Color.secondary.opacity(0.22), lineWidth: 1)
59
+                )
60
+            }
61
+        }
62
+        .padding(14)
63
+        .meterCard(
64
+            tint: .secondary,
65
+            fillOpacity: 0.10,
66
+            strokeOpacity: 0.16,
67
+            cornerRadius: 18
68
+        )
69
+    }
70
+}
+6 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -41,6 +41,12 @@ struct SidebarUSBMetersSectionView: View {
41 41
                             }
42 42
                             .buttonStyle(.plain)
43 43
                             .transition(.opacity.combined(with: .move(edge: .top)))
44
+                        } else {
45
+                            NavigationLink(destination: MeterView(offlineSummary: meterSummary)) {
46
+                                SidebarOfflineMeterCardView(summary: meterSummary)
47
+                            }
48
+                            .buttonStyle(.plain)
49
+                            .transition(.opacity.combined(with: .move(edge: .top)))
44 50
                         }
45 51
                     }
46 52
                 }