Showing 3 changed files with 640 additions and 416 deletions
+4 -0
USB Meter.xcodeproj/project.pbxproj
@@ -63,6 +63,7 @@
63 63
 		C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
64 64
 		C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
65 65
 		C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */; };
66
+		C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */; };
66 67
 		C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */; };
67 68
 		C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */; };
68 69
 		C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
@@ -186,6 +187,7 @@
186 187
 		C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
187 188
 		C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
188 189
 		C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailView.swift; sourceTree = "<group>"; };
190
+		C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceActiveSessionView.swift; sourceTree = "<group>"; };
189 191
 		C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionsView.swift; sourceTree = "<group>"; };
190 192
 		C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionDetailView.swift; sourceTree = "<group>"; };
191 193
 		C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
@@ -504,6 +506,7 @@
504 506
 			isa = PBXGroup;
505 507
 			children = (
506 508
 				C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */,
509
+				C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */,
507 510
 				C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */,
508 511
 				C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */,
509 512
 				C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */,
@@ -810,6 +813,7 @@
810 813
 				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
811 814
 				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
812 815
 				C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */,
816
+				C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */,
813 817
 				C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */,
814 818
 				C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */,
815 819
 				C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */,
+537 -0
USB Meter/Views/ChargedDevices/ChargedDeviceActiveSessionView.swift
@@ -0,0 +1,537 @@
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
+}
+99 -416
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -11,9 +11,6 @@ struct ChargedDeviceDetailView: View {
11 11
     @EnvironmentObject private var appData: AppData
12 12
     @Environment(\.dismiss) private var dismiss
13 13
     @State private var editorVisibility = false
14
-    @State private var targetNotificationEditorVisibility = false
15
-    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
16
-    @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
17 14
     @State private var deleteConfirmationVisibility = false
18 15
 
19 16
     let chargedDeviceID: UUID
@@ -31,11 +28,7 @@ struct ChargedDeviceDetailView: View {
31 28
                         }
32 29
 
33 30
                         if let activeSession = chargedDevice.activeSession {
34
-                            activeSessionCard(activeSession, chargedDevice: chargedDevice)
35
-                        }
36
-
37
-                        if let curveSession = preferredStoredCurveSession(for: chargedDevice) {
38
-                            storedCurveCard(curveSession)
31
+                            activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice)
39 32
                         }
40 33
 
41 34
                         if !chargedDevice.capacityHistory.isEmpty {
@@ -95,37 +88,6 @@ struct ChargedDeviceDetailView: View {
95 88
                 }
96 89
             }
97 90
         }
98
-        .sheet(isPresented: $targetNotificationEditorVisibility) {
99
-            if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
100
-                ChargedDeviceTargetNotificationEditorSheetView(
101
-                    sessionID: activeSession.id,
102
-                    initialTargetPercent: activeSession.targetBatteryPercent
103
-                )
104
-                .environmentObject(appData)
105
-            }
106
-        }
107
-        .sheet(item: $pendingSessionStopRequest) { request in
108
-            ChargeSessionCompletionSheetView(
109
-                sessionID: request.sessionID,
110
-                title: request.title,
111
-                confirmTitle: request.confirmTitle,
112
-                explanation: request.explanation
113
-            )
114
-            .environmentObject(appData)
115
-        }
116
-        .alert(item: $pendingCheckpointDeletion) { checkpoint in
117
-            Alert(
118
-                title: Text("Delete Battery Checkpoint"),
119
-                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
120
-                primaryButton: .destructive(Text("Delete")) {
121
-                    _ = appData.deleteBatteryCheckpoint(
122
-                        checkpointID: checkpoint.id,
123
-                        for: checkpoint.sessionID
124
-                    )
125
-                },
126
-                secondaryButton: .cancel()
127
-            )
128
-        }
129 91
         .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
130 92
             Button("Delete", role: .destructive) {
131 93
                 if appData.deleteChargedDevice(id: chargedDeviceID) {
@@ -378,152 +340,96 @@ struct ChargedDeviceDetailView: View {
378 340
         }
379 341
     }
380 342
 
381
-    private func activeSessionCard(
343
+    private func activeSessionSummaryCard(
382 344
         _ activeSession: ChargeSessionSummary,
383 345
         chargedDevice: ChargedDeviceSummary
384 346
     ) -> some View {
385
-        MeterInfoCardView(title: "Open Session", tint: .green) {
386
-            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
387
-            MeterInfoRowView(label: "Status", value: activeSession.status.title)
388
-            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
389
-            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
390
-            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
391
-            if activeSession.chargingTransportMode == .wireless,
392
-               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
393
-               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
394
-                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
395
-            }
396
-            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
397
-            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
398
-            if chargedDevice.isCharger == false,
399
-               let chargerID = activeSession.chargerID,
400
-               let charger = appData.chargedDeviceSummary(id: chargerID) {
401
-                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
402
-            }
403
-            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
404
-                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
405
-            }
406
-            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
407
-                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
408
-            }
409
-            if activeSession.chargingTransportMode == .wired,
410
-               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
411
-                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
412
-            }
413
-            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
414
-                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
415
-            }
416
-            if let targetBatteryPercent = activeSession.targetBatteryPercent {
417
-                MeterInfoRowView(
418
-                    label: "Target Notification",
419
-                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
420
-                )
421
-            }
422
-            if let sessionWarning = sessionWarning(for: activeSession) {
423
-                Text(sessionWarning)
424
-                    .font(.caption2)
425
-                    .foregroundColor(.orange)
426
-            }
427
-            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
428
-                Text(wirelessSessionHint)
429
-                    .font(.caption2)
430
-                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
431
-            }
432
-            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
433
-                MeterInfoRowView(
434
-                    label: "Predicted Battery",
435
-                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
436
-                )
437
-                Text(
438
-                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
439
-                )
440
-                .font(.caption2)
441
-                .foregroundColor(.secondary)
442
-            }
443
-
444
-            BatteryCheckpointSectionView(
445
-                sessionID: activeSession.id,
446
-                checkpoints: activeSession.checkpoints,
447
-                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
448
-                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
449
-                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
450
-                effectiveEnergyWhOverride: nil,
451
-                measuredChargeAhOverride: nil,
452
-                onDelete: { checkpoint in
453
-                    pendingCheckpointDeletion = checkpoint
454
-                }
455
-            )
347
+        NavigationLink(
348
+            destination: ChargedDeviceActiveSessionView(chargedDeviceID: chargedDevice.id)
349
+        ) {
350
+            VStack(alignment: .leading, spacing: 14) {
351
+                HStack(alignment: .firstTextBaseline) {
352
+                    VStack(alignment: .leading, spacing: 4) {
353
+                        Text("Current Session")
354
+                            .font(.headline)
355
+                            .foregroundColor(.primary)
356
+                        Text(activeSession.status.title)
357
+                            .font(.caption.weight(.semibold))
358
+                            .foregroundColor(statusTint(for: activeSession))
359
+                    }
456 360
 
457
-            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
458
-                targetNotificationEditorVisibility = true
459
-            }
460
-            .frame(maxWidth: .infinity)
461
-            .padding(.vertical, 10)
462
-            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
463
-            .buttonStyle(.plain)
361
+                    Spacer()
464 362
 
465
-            if activeSession.targetBatteryPercent != nil {
466
-                Button("Clear Target Notification") {
467
-                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
363
+                    Image(systemName: "chevron.right")
364
+                        .font(.caption.weight(.semibold))
365
+                        .foregroundColor(.secondary)
468 366
                 }
469
-                .frame(maxWidth: .infinity)
470
-                .padding(.vertical, 10)
471
-                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
472
-                .buttonStyle(.plain)
473
-            }
474 367
 
475
-            if activeSession.status == .active {
476
-                Button("Pause Session") {
477
-                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
478
-                }
479
-                .frame(maxWidth: .infinity)
480
-                .padding(.vertical, 10)
481
-                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
482
-                .buttonStyle(.plain)
483
-            } else if activeSession.status == .paused {
484
-                Button("Resume Session") {
485
-                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
368
+                LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) {
369
+                    activeSessionMetricCell(
370
+                        label: "Energy",
371
+                        value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
372
+                        tint: .teal
373
+                    )
374
+                    activeSessionMetricCell(
375
+                        label: "Duration",
376
+                        value: sessionDurationText(activeSession),
377
+                        tint: .orange
378
+                    )
379
+                    if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
380
+                        activeSessionMetricCell(
381
+                            label: "Max Power",
382
+                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W",
383
+                            tint: .blue
384
+                        )
385
+                    }
386
+                    if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
387
+                        activeSessionMetricCell(
388
+                            label: "Battery",
389
+                            value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%",
390
+                            tint: .green
391
+                        )
392
+                    } else if let targetBatteryPercent = activeSession.targetBatteryPercent {
393
+                        activeSessionMetricCell(
394
+                            label: "Target",
395
+                            value: "\(targetBatteryPercent.format(decimalDigits: 0))%",
396
+                            tint: .indigo
397
+                        )
398
+                    }
486 399
                 }
487
-                .frame(maxWidth: .infinity)
488
-                .padding(.vertical, 10)
489
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
490
-                .buttonStyle(.plain)
491 400
 
492
-                Text("Paused sessions close automatically after 10 minutes.")
493
-                    .font(.caption2)
401
+                Text("Started \(activeSession.startedAt.format())")
402
+                    .font(.caption)
494 403
                     .foregroundColor(.secondary)
495 404
             }
405
+        }
406
+        .buttonStyle(.plain)
407
+        .padding(18)
408
+        .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
409
+    }
496 410
 
497
-            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
498
-                pendingSessionStopRequest = DeviceSessionStopRequest(
499
-                    sessionID: activeSession.id,
500
-                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
501
-                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
502
-                    explanation: "Add the final battery checkpoint before closing this session."
503
-                )
504
-            }
505
-            .frame(maxWidth: .infinity)
506
-            .padding(.vertical, 10)
507
-            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
508
-            .buttonStyle(.plain)
509
-
510
-            if activeSession.requiresCompletionConfirmation {
511
-                Divider()
512
-                if let contradictionPercent = activeSession.completionContradictionPercent {
513
-                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
514
-                        .font(.caption2)
515
-                        .foregroundColor(.secondary)
516
-                }
411
+    private var activeSessionSummaryColumns: [GridItem] {
412
+        [
413
+            GridItem(.flexible(minimum: 92), spacing: 8),
414
+            GridItem(.flexible(minimum: 92), spacing: 8)
415
+        ]
416
+    }
517 417
 
518
-                Button("Keep Monitoring") {
519
-                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
520
-                }
521
-                .frame(maxWidth: .infinity)
522
-                .padding(.vertical, 10)
523
-                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
524
-                .buttonStyle(.plain)
525
-            }
418
+    private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View {
419
+        VStack(alignment: .leading, spacing: 4) {
420
+            Text(label)
421
+                .font(.caption2)
422
+                .foregroundColor(.secondary)
423
+            Text(value)
424
+                .font(.footnote.weight(.semibold))
425
+                .foregroundColor(.primary)
426
+                .monospacedDigit()
427
+                .lineLimit(1)
428
+                .minimumScaleFactor(0.8)
526 429
         }
430
+        .frame(maxWidth: .infinity, alignment: .leading)
431
+        .padding(10)
432
+        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
527 433
     }
528 434
 
529 435
     private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
@@ -554,77 +460,6 @@ struct ChargedDeviceDetailView: View {
554 460
         .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
555 461
     }
556 462
 
557
-    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
558
-        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
559
-            return activeSession
560
-        }
561
-
562
-        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
563
-    }
564
-
565
-    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
566
-        let displayedSamples = session.displayedAggregatedSamples
567
-        let currentSeries = storedSeriesSnapshot(
568
-            from: displayedSamples,
569
-            minimumYSpan: 0.15
570
-        ) { $0.averageCurrentAmps }
571
-        let energySeries = storedSeriesSnapshot(
572
-            from: displayedSamples,
573
-            minimumYSpan: 0.2
574
-        ) { $0.measuredEnergyWh }
575
-
576
-        return VStack(alignment: .leading, spacing: 14) {
577
-            HStack(alignment: .firstTextBaseline) {
578
-                VStack(alignment: .leading, spacing: 4) {
579
-                    HStack(spacing: 8) {
580
-                        Text("Stored Session Curve")
581
-                            .font(.headline)
582
-                        ContextInfoButton(
583
-                            title: "Stored Session Curve",
584
-                            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."
585
-                        )
586
-                    }
587
-                    Text(session.isTrimmed
588
-                         ? "Showing the saved trim window at aggregated resolution."
589
-                         : (session.status.isOpen
590
-                            ? "Open session, persisted as aggregated samples."
591
-                            : "Most recent persisted session at aggregated resolution."))
592
-                        .font(.caption)
593
-                        .foregroundColor(.secondary)
594
-                }
595
-
596
-                Spacer()
597
-
598
-                Text("\(displayedSamples.count) points")
599
-                    .font(.caption.weight(.semibold))
600
-                    .foregroundColor(.secondary)
601
-            }
602
-
603
-            if let currentSeries {
604
-                storedSeriesChart(
605
-                    title: "Current",
606
-                    unit: "A",
607
-                    strokeColor: .blue,
608
-                    snapshot: currentSeries
609
-                )
610
-            }
611
-
612
-            if let energySeries {
613
-                storedSeriesChart(
614
-                    title: "Energy",
615
-                    unit: "Wh",
616
-                    strokeColor: .teal,
617
-                    areaChart: true,
618
-                    snapshot: energySeries
619
-                )
620
-            }
621
-
622
-        }
623
-        .frame(maxWidth: .infinity, alignment: .leading)
624
-        .padding(18)
625
-        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
626
-    }
627
-
628 463
     private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
629 464
         VStack(alignment: .leading, spacing: 12) {
630 465
             Text("Typical Charge Curve")
@@ -713,6 +548,28 @@ struct ChargedDeviceDetailView: View {
713 548
         }
714 549
     }
715 550
 
551
+    private func statusTint(for session: ChargeSessionSummary) -> Color {
552
+        switch session.status {
553
+        case .active:
554
+            return .green
555
+        case .paused:
556
+            return .orange
557
+        case .completed:
558
+            return .teal
559
+        case .abandoned:
560
+            return .secondary
561
+        }
562
+    }
563
+
564
+    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
565
+        let formatter = DateComponentsFormatter()
566
+        let effectiveDuration = max(session.effectiveDuration, 0)
567
+        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
568
+        formatter.unitsStyle = .abbreviated
569
+        formatter.zeroFormattingBehavior = .dropAll
570
+        return formatter.string(from: effectiveDuration) ?? "0m"
571
+    }
572
+
716 573
     private func standbyEnergyLabel(_ wattHours: Double) -> String {
717 574
         if wattHours >= 1000 {
718 575
             return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
@@ -812,178 +669,4 @@ struct ChargedDeviceDetailView: View {
812 669
         return "This removes the device and its stored charging history from the library."
813 670
     }
814 671
 
815
-    private func storedSeriesSnapshot(
816
-        from samples: [ChargeSessionSampleSummary],
817
-        minimumYSpan: Double,
818
-        value: (ChargeSessionSampleSummary) -> Double
819
-    ) -> StoredSeriesSnapshot? {
820
-        let sortedSamples = samples.sorted { lhs, rhs in
821
-            if lhs.bucketIndex != rhs.bucketIndex {
822
-                return lhs.bucketIndex < rhs.bucketIndex
823
-            }
824
-            return lhs.timestamp < rhs.timestamp
825
-        }
826
-
827
-        guard
828
-            let firstSample = sortedSamples.first,
829
-            let lastSample = sortedSamples.last
830
-        else {
831
-            return nil
832
-        }
833
-
834
-        let points = sortedSamples.enumerated().map { index, sample in
835
-            Measurements.Measurement.Point(
836
-                id: index,
837
-                timestamp: sample.timestamp,
838
-                value: value(sample),
839
-                kind: .sample
840
-            )
841
-        }
842
-
843
-        let minimumValue = points.map(\.value).min() ?? 0
844
-        let maximumValue = points.map(\.value).max() ?? minimumValue
845
-        let context = ChartContext()
846
-        context.setBounds(
847
-            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
848
-            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
849
-            yMin: CGFloat(minimumValue),
850
-            yMax: CGFloat(maximumValue)
851
-        )
852
-        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
853
-
854
-        return StoredSeriesSnapshot(
855
-            points: points,
856
-            context: context,
857
-            minimumValue: minimumValue,
858
-            maximumValue: maximumValue
859
-        )
860
-    }
861
-
862
-    private func storedSeriesChart(
863
-        title: String,
864
-        unit: String,
865
-        strokeColor: Color,
866
-        areaChart: Bool = false,
867
-        snapshot: StoredSeriesSnapshot
868
-    ) -> some View {
869
-        VStack(alignment: .leading, spacing: 8) {
870
-            HStack(alignment: .firstTextBaseline) {
871
-                Text(title)
872
-                    .font(.subheadline.weight(.semibold))
873
-                Spacer()
874
-                Text(
875
-                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
876
-                )
877
-                .font(.caption2)
878
-                .foregroundColor(.secondary)
879
-            }
880
-
881
-            TimeSeriesChart(
882
-                points: snapshot.points,
883
-                context: snapshot.context,
884
-                areaChart: areaChart,
885
-                strokeColor: strokeColor
886
-            )
887
-            .frame(height: 118)
888
-            .padding(.horizontal, 6)
889
-            .padding(.vertical, 8)
890
-            .background(
891
-                RoundedRectangle(cornerRadius: 16)
892
-                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
893
-            )
894
-
895
-            HStack {
896
-                Text(snapshot.startLabel)
897
-                Spacer()
898
-                Text(snapshot.endLabel)
899
-            }
900
-            .font(.caption2)
901
-            .foregroundColor(.secondary)
902
-        }
903
-    }
904
-}
905
-
906
-private struct StoredSeriesSnapshot {
907
-    let points: [Measurements.Measurement.Point]
908
-    let context: ChartContext
909
-    let minimumValue: Double
910
-    let maximumValue: Double
911
-
912
-    var lastValue: Double {
913
-        points.last?.value ?? 0
914
-    }
915
-
916
-    var startLabel: String {
917
-        guard let firstTimestamp = points.first?.timestamp else { return "" }
918
-        return firstTimestamp.formatted(date: .omitted, time: .shortened)
919
-    }
920
-
921
-    var endLabel: String {
922
-        guard let lastTimestamp = points.last?.timestamp else { return "" }
923
-        return lastTimestamp.formatted(date: .omitted, time: .shortened)
924
-    }
925
-}
926
-
927
-private struct ChargedDeviceTargetNotificationEditorSheetView: View {
928
-    @Environment(\.dismiss) private var dismiss
929
-    @EnvironmentObject private var appData: AppData
930
-
931
-    let sessionID: UUID
932
-    let initialTargetPercent: Double?
933
-
934
-    @State private var targetPercent: Double
935
-
936
-    init(sessionID: UUID, initialTargetPercent: Double?) {
937
-        self.sessionID = sessionID
938
-        self.initialTargetPercent = initialTargetPercent
939
-        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
940
-    }
941
-
942
-    var body: some View {
943
-        NavigationView {
944
-            Form {
945
-                Section(
946
-                    header: ContextInfoHeader(
947
-                        title: "Target Level",
948
-                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
949
-                    )
950
-                ) {
951
-                    VStack(alignment: .leading, spacing: 12) {
952
-                        Text("\(targetPercent.format(decimalDigits: 0))%")
953
-                            .font(.title3.weight(.bold))
954
-                        Slider(value: $targetPercent, in: 20...100, step: 1)
955
-                    }
956
-                }
957
-            }
958
-            .navigationTitle("Battery Target")
959
-            .navigationBarTitleDisplayMode(.inline)
960
-            .toolbar {
961
-                ToolbarItem(placement: .cancellationAction) {
962
-                    Button("Cancel") {
963
-                        dismiss()
964
-                    }
965
-                }
966
-
967
-                ToolbarItem(placement: .confirmationAction) {
968
-                    Button("Save") {
969
-                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
970
-                            dismiss()
971
-                        }
972
-                    }
973
-                }
974
-            }
975
-        }
976
-        .navigationViewStyle(StackNavigationViewStyle())
977
-    }
978
-}
979
-
980
-private struct DeviceSessionStopRequest: Identifiable {
981
-    let sessionID: UUID
982
-    let title: String
983
-    let confirmTitle: String
984
-    let explanation: String
985
-
986
-    var id: UUID {
987
-        sessionID
988
-    }
989 672
 }