Newer Older
1913 lines | 75.592kb
Bogdan Timofte authored a month ago
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
Bogdan Timofte authored a month ago
54
    @State private var isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
55
    @State private var finalCheckpointText = ""
56
    @State private var stopFailureMessage: String?
57

            
58
    init(
59
        chargedDeviceID: UUID,
60
        sessionID: UUID,
61
        monitoringMeter: Meter? = nil,
62
        presentation: ChargeSessionDetailPresentation = .navigation
63
    ) {
64
        self.chargedDeviceID = chargedDeviceID
65
        self.sessionID = sessionID
66
        self.monitoringMeter = monitoringMeter
67
        self.presentation = presentation
68
    }
69

            
70
    private var chargedDevice: ChargedDeviceSummary? {
71
        appData.chargedDeviceSummary(id: chargedDeviceID)
72
    }
73

            
74
    private var session: ChargeSessionSummary? {
75
        chargedDevice?.sessions.first(where: { $0.id == sessionID })
76
    }
77

            
78
    private var liveMonitoringMeter: Meter? {
79
        guard let session,
80
              session.status.isOpen,
81
              let meterMACAddress = session.meterMACAddress else {
82
            return nil
83
        }
84

            
85
        if let monitoringMeter,
86
           monitoringMeter.btSerial.macAddress.description == meterMACAddress {
87
            return monitoringMeter
88
        }
89

            
90
        return appData.meters.values.first {
91
            $0.btSerial.macAddress.description == meterMACAddress
92
        }
93
    }
94

            
95
    private var hasMonitoringControls: Bool {
96
        session?.status.isOpen == true && liveMonitoringMeter != nil
97
    }
98

            
99
    private var shouldShowTrimBanner: Bool {
100
        guard hasMonitoringControls,
101
              let session,
102
              session.isTrimmed == false,
103
              trimBannerDismissedForSessionID != session.id,
104
              let detectedTrimWindow else {
105
            return false
106
        }
107
        return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold
108
    }
109

            
110
    var body: some View {
111
        Group {
112
            if let chargedDevice, let session {
113
                content(chargedDevice: chargedDevice, session: session)
114
            } else {
115
                unavailableState
116
            }
117
        }
118
        .sheet(item: $pendingSessionStopRequest) { request in
119
            ChargeSessionCompletionSheetView(
120
                sessionID: request.sessionID,
121
                title: request.title,
122
                confirmTitle: request.confirmTitle,
123
                explanation: request.explanation,
124
                monitoringMeter: liveMonitoringMeter,
125
                appliesTrim: request.appliesTrim,
126
                trimStart: request.trimStart,
127
                trimEnd: request.trimEnd
128
            )
129
            .environmentObject(appData)
130
        }
131
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
132
            Alert(
133
                title: Text("Delete Battery Checkpoint"),
134
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
135
                primaryButton: .destructive(Text("Delete")) {
136
                    _ = appData.deleteBatteryCheckpoint(
137
                        checkpointID: checkpoint.id,
138
                        for: checkpoint.sessionID
139
                    )
140
                },
141
                secondaryButton: .cancel()
142
            )
143
        }
144
        .alert(item: $pendingSessionDeletion) { session in
145
            Alert(
146
                title: Text("Delete Session?"),
147
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
148
                primaryButton: .destructive(Text("Delete")) {
149
                    _ = appData.deleteChargeSession(sessionID: session.id)
150
                },
151
                secondaryButton: .cancel()
152
            )
153
        }
154
        .onAppear {
155
            syncMonitoringRestore()
156
            runTrimDetection()
157
        }
158
        .onChange(of: session?.id) { _ in
159
            pendingSessionStopRequest = nil
160
            detectedTrimWindow = nil
161
            trimBannerDismissedForSessionID = nil
162
            showingInlineTargetEditor = false
163
            draftTargetText = ""
164
            showingStopConfirm = false
165
            finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
166
            isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
167
            finalCheckpointText = ""
168
            stopFailureMessage = nil
169
            syncMonitoringRestore()
170
            runTrimDetection()
171
        }
172
        .onChange(of: session?.aggregatedSamples.count) { _ in
173
            syncMonitoringRestore()
174
            runTrimDetection()
175
        }
176
        .onChange(of: finalCheckpointMode) { _ in
177
            stopFailureMessage = nil
178
        }
179
        .onChange(of: finalCheckpointText) { _ in
180
            stopFailureMessage = nil
181
        }
182
    }
183

            
184
    private func content(
185
        chargedDevice: ChargedDeviceSummary,
186
        session: ChargeSessionSummary
187
    ) -> some View {
188
        ScrollView {
189
            VStack(spacing: 16) {
190
                if hasMonitoringControls {
191
                    monitoringSessionCard(session, chargedDevice: chargedDevice)
192

            
193
                    if shouldShowTrimBanner {
194
                        trimDetectionBanner(session)
195
                    }
196

            
197
                    if shouldShowSessionChart(session) {
198
                        chartCard(session)
199
                    }
200
                } else {
201
                    overviewCard(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 batteryPrediction = chargedDevice.batteryLevelPrediction(
251
            for: session,
252
            effectiveEnergyWhOverride: displayedEnergyWh
253
        )
254

            
255
        return VStack(alignment: .leading, spacing: 14) {
256
            HStack {
257
                ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
258
                    .font(.headline)
259

            
260
                Spacer()
261

            
262
                Text(session.status.title)
263
                    .font(.caption.weight(.bold))
264
                    .foregroundColor(monitoringStatusColor(for: session))
265
                    .padding(.horizontal, 8)
266
                    .padding(.vertical, 4)
267
                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
268
            }
269

            
270
            if let batteryPrediction {
271
                batteryGaugeSection(
272
                    prediction: batteryPrediction,
273
                    session: session,
274
                    displayedEnergyWh: displayedEnergyWh
275
                )
276
            }
277

            
278
            sessionMetricsGrid(
279
                session: session,
280
                chargedDevice: chargedDevice,
281
                displayedEnergyWh: displayedEnergyWh,
282
                hasPrediction: batteryPrediction != nil
283
            )
284

            
285
            if session.stopThresholdAmps > 0 {
286
                Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
287
                    .font(.caption)
288
                    .foregroundColor(.secondary)
289
            }
290

            
291
            if let sessionWarning = sessionWarning(for: session) {
292
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
293
                    .font(.caption)
294
                    .foregroundColor(.orange)
295
            }
296

            
297
            if session.isPaused {
298
                Label(
299
                    "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
300
                    systemImage: "pause.circle"
301
                )
302
                .font(.caption)
303
                .foregroundColor(.secondary)
304
            }
305

            
306
            if session.requiresCompletionConfirmation && !showingStopConfirm {
307
                completionConfirmationCard(session)
308
            }
309

            
310
            BatteryCheckpointSectionView(
311
                sessionID: session.id,
312
                checkpoints: session.checkpoints,
313
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
314
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
315
                canDeleteCheckpoint: true,
316
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
317
                effectiveEnergyWhOverride: displayedEnergyWh,
318
                onDelete: { checkpoint in
319
                    pendingCheckpointDeletion = checkpoint
320
                }
321
            )
322

            
323
            targetSectionView(
324
                session: session,
325
                predictedPercent: batteryPrediction?.predictedPercent
326
            )
327

            
328
            if showingStopConfirm {
329
                stopConfirmPanel(
330
                    session: session,
Bogdan Timofte authored a month ago
331
                    displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
332
                )
333
            } else {
334
                monitoringActionRow(session)
335
            }
336
        }
337
        .padding(18)
338
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
339
    }
340

            
341
    private func overviewCard(
342
        _ session: ChargeSessionSummary,
343
        chargedDevice: ChargedDeviceSummary
344
    ) -> some View {
Bogdan Timofte authored a month ago
345
        MeterInfoCardView(
346
            title: session.status.isOpen ? "Open Session" : "Overview",
347
            tint: statusTint(for: session),
Bogdan Timofte authored a month ago
348
            isCollapsible: true,
349
            initiallyExpanded: false,
350
            trailingActions: {
351
                HStack(spacing: 4) {
352
                    Text(session.startedAt, style: .time)
353
                        .font(.caption2)
354
                        .foregroundColor(.secondary)
355
                        .monospacedDigit()
356
                    Text("·")
357
                        .font(.caption2)
358
                        .foregroundColor(.secondary)
359
                    Text(sessionDurationText(session))
360
                        .font(.caption2.weight(.semibold))
361
                        .foregroundColor(.secondary)
362
                        .monospacedDigit()
363
                }
364
            }
Bogdan Timofte authored a month ago
365
        ) {
366
            VStack(alignment: .leading, spacing: 10) {
367
                MeterInfoRowView(label: "Device", value: chargedDevice.name)
368

            
369
                Divider()
370

            
371
                HStack(alignment: .top, spacing: 12) {
372
                    overviewStatCell(label: "Started", value: session.startedAt.format())
373
                    if let endedAt = session.endedAt {
374
                        overviewStatCell(label: "Ended", value: endedAt.format())
375
                    }
376
                }
377

            
378
                HStack(alignment: .top, spacing: 12) {
379
                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
380
                    overviewStatCell(label: "Status", value: session.status.title)
381
                }
382

            
383
                Divider()
384

            
385
                HStack(alignment: .top, spacing: 12) {
386
                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
387
                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
388
                }
389

            
390
                HStack(alignment: .top, spacing: 12) {
391
                    overviewStatCell(label: "Source", value: session.sourceMode.title)
392
                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
393
                }
394

            
395
                if session.isTrimmed {
396
                    Divider()
397
                    HStack(alignment: .top, spacing: 12) {
398
                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
399
                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
400
                    }
401
                }
402

            
403
                let meterLabel: String? = session.meterName ?? session.meterMACAddress
404
                if meterLabel != nil || session.meterModel != nil {
405
                    Divider()
406
                    HStack(alignment: .top, spacing: 12) {
407
                        if let label = meterLabel {
408
                            overviewStatCell(label: "Meter", value: label)
409
                        }
410
                        if let model = session.meterModel {
411
                            overviewStatCell(label: "Meter Model", value: model)
412
                        }
413
                    }
414
                }
Bogdan Timofte authored a month ago
415

            
416
                if session.minimumObservedCurrentAmps != nil
417
                    || session.maximumObservedCurrentAmps != nil
418
                    || session.maximumObservedPowerWatts != nil
419
                    || session.maximumObservedVoltageVolts != nil
420
                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
421
                    || session.completionCurrentAmps != nil
422
                    || session.selectedDataGroup != nil {
423

            
424
                    Divider()
425

            
426
                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
427
                        HStack(alignment: .top, spacing: 12) {
428
                            if let v = session.minimumObservedCurrentAmps {
429
                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
430
                            }
431
                            if let v = session.maximumObservedCurrentAmps {
432
                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
433
                            }
434
                        }
435
                    }
436

            
437
                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
438
                        HStack(alignment: .top, spacing: 12) {
439
                            if let v = session.maximumObservedPowerWatts {
440
                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
441
                            }
442
                            if let v = session.maximumObservedVoltageVolts {
443
                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
444
                            }
445
                        }
446
                    }
447

            
448
                    if session.completionCurrentAmps != nil
449
                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
450
                        HStack(alignment: .top, spacing: 12) {
451
                            if let v = session.completionCurrentAmps {
452
                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
453
                            }
454
                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
455
                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
456
                            }
457
                        }
458
                    }
459

            
460
                    if let dg = session.selectedDataGroup {
461
                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
462
                    }
463
                }
Bogdan Timofte authored a month ago
464
            }
465
        }
466
    }
467

            
Bogdan Timofte authored a month ago
468
    private func overviewStatCell(label: String, value: String) -> some View {
469
        VStack(alignment: .leading, spacing: 2) {
470
            Text(label)
471
                .font(.caption2)
472
                .foregroundColor(.secondary)
473
            Text(value)
474
                .font(.footnote.weight(.medium))
475
                .monospacedDigit()
476
        }
477
        .frame(maxWidth: .infinity, alignment: .leading)
478
    }
479

            
Bogdan Timofte authored a month ago
480
    private func batteryCard(
481
        _ session: ChargeSessionSummary,
482
        chargedDevice: ChargedDeviceSummary
483
    ) -> some View {
484
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
485
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
486
            for: session,
487
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
488
        )
489

            
Bogdan Timofte authored a month ago
490
        let startPercent = session.startBatteryPercent
491
        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
492
        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
493
        let showsPreview = startPercent != nil && endPercent != nil
Bogdan Timofte authored a month ago
494

            
Bogdan Timofte authored a month ago
495
        return VStack(alignment: .leading, spacing: 0) {
496

            
497
            // Header — always visible, tappable
498
            HStack(spacing: 8) {
499
                Text("Battery")
500
                    .font(.headline)
501
                Spacer(minLength: 0)
502
                Image(systemName: "chevron.up")
503
                    .font(.caption.weight(.semibold))
504
                    .foregroundColor(.secondary)
505
                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
506
                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
507
            }
508
            .contentShape(Rectangle())
509
            .onTapGesture {
510
                withAnimation(.easeInOut(duration: 0.25)) {
511
                    isBatteryCardExpanded.toggle()
Bogdan Timofte authored a month ago
512
                }
Bogdan Timofte authored a month ago
513
            }
Bogdan Timofte authored a month ago
514

            
Bogdan Timofte authored a month ago
515
            // Preview bar — always visible when there is enough data
516
            if showsPreview, let start = startPercent, let end = endPercent {
517
                batteryPreviewBar(
518
                    startPercent: start,
519
                    endPercent: end,
520
                    checkpoints: session.checkpoints,
521
                    isEstimatedEnd: isEstimatedEnd
522
                )
523
                .padding(.top, 10)
524
            }
525

            
526
            // Collapsible detail
527
            if isBatteryCardExpanded {
528
                VStack(alignment: .leading, spacing: 10) {
529

            
530
                    // Energy
Bogdan Timofte authored a month ago
531
                    HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
532
                        overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
533
                        if let capacityEstimateWh = session.capacityEstimateWh {
534
                            overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
535
                        }
536
                    }
Bogdan Timofte authored a month ago
537
                    if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
538
                        MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
539
                    }
540
                    if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
541
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
542
                        MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
543
                    }
544
                    if let chargerID = session.chargerID,
545
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
546
                        MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
547
                    }
548
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
549
                        Text(wirelessSessionHint)
550
                            .font(.caption2)
551
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
552
                    }
553
                    if let sessionWarning = sessionWarning(for: session) {
554
                        Label(sessionWarning, systemImage: "exclamationmark.triangle")
555
                            .font(.caption2)
556
                            .foregroundColor(.orange)
557
                    }
558

            
559
                    // Battery percentages
560
                    if startPercent != nil || session.endBatteryPercent != nil {
561
                        Divider()
Bogdan Timofte authored a month ago
562
                        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
563
                            if let v = startPercent {
564
                                overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
565
                            }
Bogdan Timofte authored a month ago
566
                            if let v = session.endBatteryPercent {
567
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
568
                            }
569
                        }
Bogdan Timofte authored a month ago
570
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
571
                            HStack(alignment: .top, spacing: 12) {
572
                                if let v = session.batteryDeltaPercent {
573
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
574
                                }
575
                                if let v = session.targetBatteryPercent {
576
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
577
                                }
578
                            }
579
                        }
580
                        if let batteryPrediction {
581
                            HStack(alignment: .top, spacing: 12) {
582
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
583
                            }
584
                            Text(
585
                                "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
586
                            )
587
                            .font(.caption2)
588
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
589
                        }
590
                    }
Bogdan Timofte authored a month ago
591

            
592
                    // Checkpoints
593
                    Divider()
594
                    BatteryCheckpointSectionView(
595
                        sessionID: session.id,
596
                        checkpoints: session.checkpoints,
597
                        message: session.status.isOpen
598
                            ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
599
                            : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
600
                        canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
601
                        canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
602
                        requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
603
                        effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
604
                        onDelete: { checkpoint in
605
                            pendingCheckpointDeletion = checkpoint
606
                        }
607
                    )
Bogdan Timofte authored a month ago
608
                }
Bogdan Timofte authored a month ago
609
                .padding(.top, 12)
610
                .transition(.opacity.combined(with: .move(edge: .top)))
611
            }
612
        }
613
        .frame(maxWidth: .infinity, alignment: .leading)
614
        .padding(18)
615
        .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
616
    }
Bogdan Timofte authored a month ago
617

            
Bogdan Timofte authored a month ago
618
    private func batteryPreviewBar(
619
        startPercent: Double,
620
        endPercent: Double,
621
        checkpoints: [ChargeCheckpointSummary],
622
        isEstimatedEnd: Bool
623
    ) -> some View {
624
        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
625
        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
626
        let color = batteryColor(for: endPercent)
627

            
628
        return HStack(spacing: 6) {
629
            Text("\(Int(startPercent.rounded()))%")
630
                .font(.caption2.weight(.semibold))
631
                .foregroundColor(.secondary)
632
                .monospacedDigit()
633
                .frame(minWidth: 26, alignment: .trailing)
634

            
635
            GeometryReader { geo in
636
                let w = geo.size.width
637
                ZStack(alignment: .leading) {
638
                    Capsule()
639
                        .fill(Color.primary.opacity(0.10))
640

            
641
                    Rectangle()
642
                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
643
                        .frame(width: max(w * (endFrac - startFrac), 3))
644
                        .offset(x: w * startFrac)
645

            
646
                    ForEach(checkpoints, id: \.id) { cp in
647
                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
648
                        let isFinal = cp.flag == .final
649
                        Rectangle()
650
                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
651
                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
652
                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
Bogdan Timofte authored a month ago
653
                    }
Bogdan Timofte authored a month ago
654
                }
655
                .clipShape(Capsule())
656
            }
657
            .frame(height: 8)
658

            
659
            HStack(spacing: 1) {
660
                if isEstimatedEnd {
661
                    Text("~")
662
                        .font(.caption2)
663
                        .foregroundColor(.secondary)
664
                }
665
                Text("\(Int(endPercent.rounded()))%")
666
                    .font(.caption2.weight(.semibold))
667
                    .foregroundColor(isEstimatedEnd ? .secondary : color)
668
                    .monospacedDigit()
Bogdan Timofte authored a month ago
669
            }
Bogdan Timofte authored a month ago
670
            .frame(minWidth: 32, alignment: .leading)
Bogdan Timofte authored a month ago
671
        }
672
    }
673

            
674
    private func batteryGaugeSection(
675
        prediction: BatteryLevelPrediction,
676
        session: ChargeSessionSummary,
677
        displayedEnergyWh: Double
678
    ) -> some View {
679
        let percent = prediction.predictedPercent
680
        let color = batteryColor(for: percent)
681
        let duration = displayedSessionDuration(for: session)
682
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
683
            ? displayedEnergyWh / duration
684
            : nil
685
        let etaToFull = etaText(
686
            rateWhPerSec: rateWhPerSec,
687
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
688
            isRelevant: percent < 98
689
        )
690
        let etaToTarget = etaToTargetText(
691
            session: session,
692
            prediction: prediction,
693
            displayedEnergyWh: displayedEnergyWh,
694
            rateWhPerSec: rateWhPerSec
695
        )
696

            
697
        return VStack(spacing: 10) {
698
            HStack(alignment: .lastTextBaseline, spacing: 8) {
699
                HStack(alignment: .lastTextBaseline, spacing: 3) {
700
                    Text("\(Int(percent.rounded()))")
701
                        .font(.system(size: 52, weight: .bold, design: .rounded))
702
                        .foregroundColor(color)
703
                        .monospacedDigit()
704
                    Text("%")
705
                        .font(.title2.weight(.semibold))
706
                        .foregroundColor(color.opacity(0.8))
707
                }
708

            
709
                Spacer()
710

            
711
                VStack(alignment: .trailing, spacing: 2) {
712
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
713
                        .font(.callout.weight(.bold))
714
                        .foregroundColor(.orange)
715
                        .monospacedDigit()
716
                    Text("est. capacity")
717
                        .font(.caption2)
718
                        .foregroundColor(.secondary)
719
                }
720
            }
721

            
722
            batteryProgressBar(
723
                percent: percent,
724
                startPercent: session.startBatteryPercent,
725
                targetPercent: session.targetBatteryPercent
726
            )
727

            
728
            HStack(spacing: 14) {
729
                if let etaToFull {
730
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
731
                }
732

            
733
                if let etaToTarget, let target = session.targetBatteryPercent {
734
                    etaPill(
735
                        icon: "bell.badge.fill",
736
                        tint: .indigo,
737
                        value: etaToTarget,
738
                        label: "to \(Int(target.rounded()))%"
739
                    )
740
                }
741

            
742
                Spacer()
743

            
744
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
745
                    .font(.caption2)
746
                    .foregroundColor(.secondary)
747
                    .multilineTextAlignment(.trailing)
748
            }
749
        }
750
        .padding(14)
751
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
752
    }
753

            
754
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
755
        VStack(alignment: .leading, spacing: 1) {
756
            HStack(spacing: 4) {
757
                Image(systemName: icon)
758
                    .font(.caption)
759
                    .foregroundColor(tint)
760
                Text(value)
761
                    .font(.caption.weight(.bold))
762
            }
763
            Text(label)
764
                .font(.caption2)
765
                .foregroundColor(.secondary)
766
        }
767
    }
768

            
769
    private func batteryProgressBar(
770
        percent: Double,
771
        startPercent: Double?,
772
        targetPercent: Double?
773
    ) -> some View {
774
        let color = batteryColor(for: percent)
775
        return GeometryReader { geo in
776
            let width = geo.size.width
777
            ZStack(alignment: .leading) {
778
                Capsule()
779
                    .fill(Color.primary.opacity(0.10))
780
                Rectangle()
781
                    .fill(
782
                        LinearGradient(
783
                            colors: [color.opacity(0.6), color],
784
                            startPoint: .leading,
785
                            endPoint: .trailing
786
                        )
787
                    )
788
                    .frame(width: max(width * CGFloat(percent / 100), 4))
789
                    .animation(.easeInOut(duration: 0.4), value: percent)
790
                if let start = startPercent, start > 2, start < 98 {
791
                    Rectangle()
792
                        .fill(Color.white.opacity(0.55))
793
                        .frame(width: 2, height: 20)
794
                        .offset(x: width * CGFloat(start / 100) - 1)
795
                }
796
                if let target = targetPercent {
797
                    Rectangle()
798
                        .fill(Color.indigo.opacity(0.9))
799
                        .frame(width: 2.5, height: 20)
800
                        .offset(x: width * CGFloat(target / 100) - 1.25)
801
                }
802
            }
803
            .clipShape(Capsule())
804
        }
805
        .frame(height: 20)
806
    }
807

            
808
    private func sessionMetricsGrid(
809
        session: ChargeSessionSummary,
810
        chargedDevice: ChargedDeviceSummary,
811
        displayedEnergyWh: Double,
812
        hasPrediction: Bool
813
    ) -> some View {
814
        let capacityFallback: Double? = hasPrediction ? nil : (
815
            session.capacityEstimateWh
816
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
817
                ?? chargedDevice.estimatedBatteryCapacityWh
818
        )
819
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
820

            
821
        return LazyVGrid(columns: columns, spacing: 8) {
822
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
823
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
824

            
825
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
826
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
827
            }
828
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
829
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
830
            }
831

            
832
            metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
833

            
834
            if let capacityFallback {
835
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
836
            }
837
        }
838
    }
839

            
840
    private func metricCell(label: String, value: String, tint: Color) -> some View {
841
        VStack(alignment: .leading, spacing: 3) {
842
            Text(label)
843
                .font(.caption2)
844
                .foregroundColor(.secondary)
845
            Text(value)
846
                .font(.subheadline.weight(.semibold))
847
                .lineLimit(1)
848
                .minimumScaleFactor(0.7)
849
                .monospacedDigit()
850
        }
851
        .frame(maxWidth: .infinity, alignment: .leading)
852
        .padding(.horizontal, 12)
853
        .padding(.vertical, 10)
854
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
855
    }
856

            
857
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
858
        VStack(alignment: .leading, spacing: 10) {
859
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
860
                .font(.subheadline.weight(.semibold))
861

            
862
            if let contradictionPercent = session.completionContradictionPercent {
863
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
864
                    .font(.caption)
865
                    .foregroundColor(.secondary)
866
            } else {
867
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
868
                    .font(.caption)
869
                    .foregroundColor(.secondary)
870
            }
871

            
872
            HStack(spacing: 10) {
873
                Button("Finish") {
874
                    beginStopConfirmation(for: session)
875
                }
876
                .frame(maxWidth: .infinity)
877
                .padding(.vertical, 9)
878
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
879
                .buttonStyle(.plain)
880

            
881
                Button("Keep Monitoring") {
882
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
883
                }
884
                .frame(maxWidth: .infinity)
885
                .padding(.vertical, 9)
886
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
887
                .buttonStyle(.plain)
888
            }
889
        }
890
        .padding(14)
891
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
892
    }
893

            
894
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
895
        let draftBelowPrediction: Bool = {
896
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
897
            return draft <= predictedPercent
898
        }()
899
        let savedBelowPrediction: Bool = {
900
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
901
            return saved <= predictedPercent
902
        }()
903

            
904
        return HStack(alignment: .center, spacing: 8) {
905
            Image(systemName: "bell.badge")
906
                .foregroundColor(.indigo)
907
                .font(.subheadline)
908

            
909
            Text("Notify at")
910
                .font(.subheadline.weight(.semibold))
911

            
912
            Spacer(minLength: 8)
913

            
914
            if showingInlineTargetEditor {
915
                targetEditorControls(
916
                    session: session,
917
                    draftBelowPrediction: draftBelowPrediction,
918
                    predictedPercent: predictedPercent
919
                )
920
            } else {
921
                savedTargetControls(
922
                    session: session,
923
                    savedBelowPrediction: savedBelowPrediction,
924
                    predictedPercent: predictedPercent
925
                )
926
            }
927
        }
928
    }
929

            
930
    private func targetEditorControls(
931
        session: ChargeSessionSummary,
932
        draftBelowPrediction: Bool,
933
        predictedPercent: Double?
934
    ) -> some View {
935
        Group {
936
            Button {
937
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
938
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
939
            } label: {
940
                Image(systemName: "minus.circle")
941
                    .font(.title3)
942
            }
943
            .buttonStyle(.plain)
944

            
945
            TextField("-", text: $draftTargetText)
946
                .keyboardType(.decimalPad)
947
                .textFieldStyle(.roundedBorder)
948
                .frame(width: 48)
949
                .multilineTextAlignment(.center)
950
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
951

            
952
            Text("%")
953
                .font(.subheadline)
954
                .foregroundColor(.secondary)
955

            
956
            if draftBelowPrediction, let predictedPercent {
957
                predictionWarningButton(predictedPercent: predictedPercent)
958
            }
959

            
960
            Button {
961
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
962
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
963
            } label: {
964
                Image(systemName: "plus.circle")
965
                    .font(.title3)
966
            }
967
            .buttonStyle(.plain)
968

            
969
            Button {
970
                if let value = parsedDraftTarget {
971
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
972
                }
973
                showingInlineTargetEditor = false
974
            } label: {
975
                Image(systemName: "checkmark.circle.fill")
976
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
977
                    .font(.title3)
978
            }
979
            .buttonStyle(.plain)
980
            .disabled(parsedDraftTarget == nil)
981

            
982
            Button {
983
                showingInlineTargetEditor = false
984
                draftTargetText = ""
985
            } label: {
986
                Image(systemName: "xmark.circle")
987
                    .foregroundColor(.secondary)
988
                    .font(.title3)
989
            }
990
            .buttonStyle(.plain)
991
        }
992
    }
993

            
994
    private func savedTargetControls(
995
        session: ChargeSessionSummary,
996
        savedBelowPrediction: Bool,
997
        predictedPercent: Double?
998
    ) -> some View {
999
        Group {
1000
            if let targetPercent = session.targetBatteryPercent {
1001
                Text("\(targetPercent.format(decimalDigits: 0))%")
1002
                    .font(.subheadline.weight(.semibold))
1003
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1004

            
1005
                if savedBelowPrediction, let predictedPercent {
1006
                    predictionWarningButton(predictedPercent: predictedPercent)
1007
                }
1008

            
1009
                Button {
1010
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
1011
                } label: {
1012
                    Image(systemName: "xmark.circle.fill")
1013
                        .foregroundColor(.secondary)
1014
                        .font(.callout)
1015
                }
1016
                .buttonStyle(.plain)
1017
                .help("Remove alert")
1018
            }
1019

            
1020
            Button {
1021
                draftTargetText = session.targetBatteryPercent.map {
1022
                    $0.format(decimalDigits: 0)
1023
                } ?? "80"
1024
                showingInlineTargetEditor = true
1025
            } label: {
1026
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1027
                    .font(.caption.weight(.semibold))
1028
                    .frame(width: 30, height: 30)
1029
                    .contentShape(Rectangle())
1030
            }
1031
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1032
            .buttonStyle(.plain)
1033
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1034
        }
1035
    }
1036

            
1037
    private func predictionWarningButton(predictedPercent: Double) -> some View {
1038
        Button {} label: {
1039
            Image(systemName: "exclamationmark.triangle.fill")
1040
                .font(.callout.weight(.semibold))
1041
                .foregroundColor(.orange)
1042
        }
1043
        .buttonStyle(.plain)
1044
        .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.")
1045
    }
1046

            
1047
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
1048
        HStack(spacing: 10) {
1049
            if session.status == .active {
1050
                Button("Pause") {
1051
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1052
                }
1053
                .monitoringActionStyle(tint: .orange)
1054
            } else if session.status == .paused {
1055
                Button("Resume") {
1056
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1057
                }
1058
                .monitoringActionStyle(tint: .blue)
1059
            }
1060

            
1061
            Button("Terminate Session") {
1062
                beginStopConfirmation(for: session)
1063
            }
1064
            .monitoringActionStyle(tint: .red)
1065
        }
1066
    }
1067

            
1068
    private func stopConfirmPanel(
1069
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1070
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1071
    ) -> some View {
1072
        let canSave = hasSavableChargeData(
1073
            session: session,
Bogdan Timofte authored a month ago
1074
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1075
        )
1076
        let saveDisabledReason = saveDisabledReason(
1077
            session: session,
Bogdan Timofte authored a month ago
1078
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1079
        )
1080
        let isSaveEnabled = saveDisabledReason == nil
1081

            
1082
        return VStack(alignment: .leading, spacing: 12) {
1083
            HStack {
1084
                Text("Final Checkpoint")
1085
                    .font(.subheadline.weight(.semibold))
1086
                Text("optional")
1087
                    .font(.caption2.weight(.semibold))
1088
                    .foregroundColor(.secondary)
1089
            }
1090

            
1091
            finalCheckpointPicker(session)
1092

            
1093
            if finalCheckpointMode == .custom {
1094
                customFinalCheckpointRow
1095
            }
1096

            
1097
            if let saveDisabledReason {
1098
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
1099
                    .font(.caption)
1100
                    .foregroundColor(.red)
1101
                    .fixedSize(horizontal: false, vertical: true)
1102
            } else if let stopFailureMessage {
1103
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
1104
                    .font(.caption)
1105
                    .foregroundColor(.red)
1106
                    .fixedSize(horizontal: false, vertical: true)
1107
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
1108
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
1109
                    .font(.caption)
1110
                    .foregroundColor(.green)
1111
                    .fixedSize(horizontal: false, vertical: true)
1112
            }
1113

            
1114
            HStack(spacing: 8) {
1115
                Button("Discard") {
1116
                    discardSession(session)
1117
                }
1118
                .monitoringPanelActionStyle(tint: .secondary)
1119

            
1120
                Button {
1121
                    stopSession(
1122
                        session,
Bogdan Timofte authored a month ago
1123
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1124
                    )
1125
                } label: {
1126
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1127
                        .frame(maxWidth: .infinity)
1128
                }
1129
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1130
                .disabled(!isSaveEnabled)
1131
                .help(saveDisabledReason ?? "Close and save this session")
1132

            
1133
                Button("Cancel") {
1134
                    resetStopConfirmation()
1135
                }
1136
                .monitoringPanelActionStyle(tint: .secondary)
1137
            }
1138
        }
1139
        .padding(14)
1140
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1141
    }
1142

            
1143
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1144
        return HStack(spacing: 8) {
1145
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1146
                Button {
1147
                    finalCheckpointMode = mode
1148
                    if mode == .custom {
1149
                        prefillFinalCheckpointIfNeeded(for: session)
1150
                    } else {
1151
                        finalCheckpointText = ""
1152
                    }
1153
                } label: {
1154
                    VStack(spacing: 5) {
1155
                        Image(systemName: mode.icon)
1156
                            .font(.title3)
1157
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1158
                        Text(mode.label)
1159
                            .font(.caption.weight(.semibold))
1160
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1161
                    }
1162
                    .frame(maxWidth: .infinity)
1163
                    .padding(.vertical, 10)
1164
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1165
                    .meterCard(
1166
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1167
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1168
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1169
                        cornerRadius: 12
1170
                    )
1171
                }
1172
                .buttonStyle(.plain)
1173
            }
1174
        }
1175
    }
1176

            
1177
    private var customFinalCheckpointRow: some View {
1178
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1179
            || parsedFinalCheckpoint == nil
1180

            
1181
        return HStack(spacing: 8) {
1182
            Button {
1183
                adjustFinalCheckpoint(by: -1)
1184
            } label: {
1185
                Image(systemName: "minus.circle").font(.title3)
1186
            }
1187
            .buttonStyle(.plain)
1188

            
1189
            TextField("-", text: $finalCheckpointText)
1190
                .keyboardType(.decimalPad)
1191
                .textFieldStyle(.roundedBorder)
1192
                .frame(width: 56)
1193
                .multilineTextAlignment(.center)
1194
                .overlay(
1195
                    RoundedRectangle(cornerRadius: 6)
1196
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1197
                )
1198

            
1199
            Text("%").foregroundColor(.secondary)
1200

            
1201
            Text("required")
1202
                .font(.caption2.weight(.semibold))
1203
                .foregroundColor(isInvalid ? .red : .secondary)
1204

            
1205
            Button {
1206
                adjustFinalCheckpoint(by: 1)
1207
            } label: {
1208
                Image(systemName: "plus.circle").font(.title3)
1209
            }
1210
            .buttonStyle(.plain)
1211

            
1212
            Spacer()
1213
        }
1214
    }
1215

            
1216
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1217
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1218
            if let meterName = session.meterName {
1219
                MeterInfoRowView(label: "Controlled On", value: meterName)
1220
            }
1221
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1222
                .font(.caption2)
1223
                .foregroundColor(.secondary)
1224
        }
1225
    }
1226

            
1227
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1228
        MeterInfoCardView(title: "Administration", tint: .red) {
1229
            Button(role: .destructive) {
1230
                pendingSessionDeletion = session
1231
            } label: {
1232
                Label("Delete Session", systemImage: "trash")
1233
                    .font(.subheadline.weight(.semibold))
1234
                    .frame(maxWidth: .infinity)
1235
                    .padding(.vertical, 10)
1236
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1237
            }
1238
            .buttonStyle(.plain)
1239
        }
1240
    }
1241

            
1242
    @ViewBuilder
1243
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1244
        if let window = detectedTrimWindow {
1245
            HStack(spacing: 12) {
1246
                Image(systemName: "scissors.circle.fill")
1247
                    .font(.title3)
1248
                    .foregroundColor(.blue)
1249

            
1250
                VStack(alignment: .leading, spacing: 2) {
1251
                    Text("Charging ended early")
1252
                        .font(.subheadline.weight(.semibold))
1253
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1254
                        .font(.caption)
1255
                        .foregroundColor(.secondary)
1256
                        .fixedSize(horizontal: false, vertical: true)
1257
                }
1258

            
1259
                Spacer(minLength: 0)
1260

            
1261
                VStack(spacing: 6) {
1262
                    Button("Trim Start") {
1263
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1264
                        trimBannerDismissedForSessionID = session.id
1265
                    }
1266
                    .font(.caption.weight(.semibold))
1267
                    .buttonStyle(.borderedProminent)
1268
                    .controlSize(.small)
1269
                    .tint(.blue)
1270

            
1271
                    Button("End & Finish") {
1272
                        requestStop(
1273
                            session,
1274
                            applyingTrimStart: session.trimStart ?? window.start,
1275
                            trimEnd: window.end,
1276
                            title: "Trim End & Finish",
1277
                            confirmTitle: "Finish",
1278
                            explanation: "The detected charging window will be saved before the session is closed."
1279
                        )
1280
                        trimBannerDismissedForSessionID = session.id
1281
                    }
1282
                    .font(.caption.weight(.semibold))
1283
                    .buttonStyle(.bordered)
1284
                    .controlSize(.small)
1285
                    .tint(.red)
1286
                }
1287
            }
1288
            .padding(14)
1289
            .background(
1290
                RoundedRectangle(cornerRadius: 14)
1291
                    .fill(Color.blue.opacity(0.10))
1292
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1293
            )
1294
            .transition(.opacity.combined(with: .move(edge: .top)))
1295
        }
1296
    }
1297

            
1298
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1299
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1300
    }
1301

            
1302
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1303
        ChargeSessionChartCardView(
1304
            session: session,
1305
            monitoringMeter: liveMonitoringMeter,
1306
            controlMode: chartControlMode(for: session),
1307
            onSetTrim: { start, end in
1308
                setSessionTrim(sessionID: session.id, start: start, end: end)
1309
            },
1310
            onStopWithTrim: { start, end in
1311
                requestStop(
1312
                    session,
1313
                    applyingTrimStart: start,
1314
                    trimEnd: end,
1315
                    title: "Trim End & Finish",
1316
                    confirmTitle: "Finish",
1317
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1318
                )
1319
            }
1320
        )
1321
    }
1322

            
1323
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1324
        if hasMonitoringControls {
1325
            return .activeMonitoring
1326
        }
1327

            
1328
        if session.status.isOpen == false {
1329
            return .closed
1330
        }
1331

            
1332
        return .none
1333
    }
1334

            
1335
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1336
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1337
        trimBannerDismissedForSessionID = sessionID
1338
    }
1339

            
1340
    private func requestStop(
1341
        _ session: ChargeSessionSummary,
1342
        applyingTrimStart trimStart: Date?,
1343
        trimEnd: Date?,
1344
        title: String,
1345
        confirmTitle: String,
1346
        explanation: String
1347
    ) {
1348
        pendingSessionStopRequest = ChargeSessionStopRequest(
1349
            sessionID: session.id,
1350
            title: title,
1351
            confirmTitle: confirmTitle,
1352
            explanation: explanation,
1353
            appliesTrim: trimStart != nil || trimEnd != nil,
1354
            trimStart: trimStart,
1355
            trimEnd: trimEnd
1356
        )
1357
    }
1358

            
1359
    private var parsedDraftTarget: Double? {
1360
        let normalized = draftTargetText
1361
            .trimmingCharacters(in: .whitespacesAndNewlines)
1362
            .replacingOccurrences(of: ",", with: ".")
1363
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1364
        return value
1365
    }
1366

            
1367
    private var parsedFinalCheckpoint: Double? {
1368
        let normalized = finalCheckpointText
1369
            .trimmingCharacters(in: .whitespacesAndNewlines)
1370
            .replacingOccurrences(of: ",", with: ".")
1371
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1372
        return value
1373
    }
1374

            
1375
    private var resolvedFinalCheckpoint: Double? {
1376
        switch finalCheckpointMode {
1377
        case .full:   return 100
1378
        case .skip:   return nil
1379
        case .custom: return parsedFinalCheckpoint
1380
        }
1381
    }
1382

            
1383
    private func adjustFinalCheckpoint(by delta: Double) {
1384
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1385
        let next = min(max(current + delta, 0), 100)
1386
        finalCheckpointText = next.format(decimalDigits: 0)
1387
    }
1388

            
1389
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1390
        guard let session else { return nil }
1391
        if let endBatteryPercent = session.endBatteryPercent {
1392
            return endBatteryPercent
1393
        }
1394
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1395
            return latestCheckpoint.batteryPercent
1396
        }
1397
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1398
    }
1399

            
1400
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1401
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1402
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1403
            return
1404
        }
1405
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1406
    }
1407

            
1408
    private func hasSavableChargeData(
1409
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1410
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1411
    ) -> Bool {
1412
        session.hasSavableChargeData
1413
            || displayedEnergyWh > 0
1414
    }
1415

            
1416
    private func saveDisabledReason(
1417
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1418
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1419
    ) -> String? {
1420
        if finalCheckpointMode == .custom {
1421
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1422
            if trimmed.isEmpty {
1423
                return "Enter the final battery percentage or choose Skip."
1424
            }
1425
            if parsedFinalCheckpoint == nil {
1426
                return "Final battery percentage must be between 0 and 100."
1427
            }
1428
        }
1429

            
1430
        guard hasSavableChargeData(
1431
            session: session,
Bogdan Timofte authored a month ago
1432
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1433
        ) else {
1434
            return "This session has no charging data to save. Discard it instead."
1435
        }
1436

            
1437
        return nil
1438
    }
1439

            
1440
    private func stopSession(
1441
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1442
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1443
    ) {
1444
        stopFailureMessage = nil
1445

            
1446
        if let saveDisabledReason = saveDisabledReason(
1447
            session: session,
Bogdan Timofte authored a month ago
1448
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1449
        ) {
1450
            stopFailureMessage = saveDisabledReason
1451
            return
1452
        }
1453

            
1454
        let didSave = appData.stopChargeSession(
1455
            sessionID: session.id,
1456
            finalBatteryPercent: resolvedFinalCheckpoint,
1457
            from: liveMonitoringMeter
1458
        )
1459
        if didSave {
1460
            resetStopConfirmation()
1461
        } else {
1462
            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."
1463
        }
1464
    }
1465

            
1466
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1467
        finalCheckpointMode = .skip
1468
        finalCheckpointText = ""
1469
        stopFailureMessage = nil
1470
        showingStopConfirm = true
1471
    }
1472

            
1473
    private func discardSession(_ session: ChargeSessionSummary) {
1474
        _ = appData.deleteChargeSession(sessionID: session.id)
1475
        resetStopConfirmation()
1476
    }
1477

            
1478
    private func resetStopConfirmation() {
1479
        showingStopConfirm = false
1480
        finalCheckpointText = ""
1481
        finalCheckpointMode = .skip
1482
        stopFailureMessage = nil
1483
    }
1484

            
1485
    private func syncMonitoringRestore() {
1486
        guard let session,
1487
              session.status.isOpen,
1488
              let liveMonitoringMeter,
1489
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1490
            return
1491
        }
1492
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1493
    }
1494

            
1495
    private func runTrimDetection() {
1496
        guard hasMonitoringControls,
1497
              let session,
1498
              session.isTrimmed == false,
1499
              !session.aggregatedSamples.isEmpty else {
1500
            detectedTrimWindow = nil
1501
            return
1502
        }
1503

            
1504
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1505
        detectedTrimWindow = ChargingWindowDetector.detect(
1506
            samples: session.aggregatedSamples,
1507
            sessionStart: session.startedAt,
1508
            sessionEnd: sessionEnd
1509
        )
1510
    }
1511

            
1512
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1513
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1514
        guard session.isTrimmed == false else { return storedEnergyWh }
1515
        guard session.status.isOpen else { return storedEnergyWh }
1516
        guard let liveMonitoringMeter else { return storedEnergyWh }
1517
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1518
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1519
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1520
        }
1521
        return storedEnergyWh
1522
    }
1523

            
1524
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1525
        let storedDuration = max(session.effectiveDuration, 0)
1526
        guard session.isTrimmed == false else { return storedDuration }
1527
        guard session.status.isOpen else { return storedDuration }
1528
        guard let liveMonitoringMeter else { return storedDuration }
1529
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1530
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1531
    }
1532

            
1533
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1534
        let displayedDuration = displayedSessionDuration(for: session)
1535
        let formatter = DateComponentsFormatter()
1536
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1537
        formatter.unitsStyle = .abbreviated
1538
        formatter.zeroFormattingBehavior = .dropAll
1539
        return formatter.string(from: displayedDuration) ?? "0m"
1540
    }
1541

            
1542
    private func formatDuration(_ duration: TimeInterval) -> String {
1543
        let totalSeconds = Int(duration.rounded(.down))
1544
        let hours = totalSeconds / 3600
1545
        let minutes = (totalSeconds % 3600) / 60
1546
        let seconds = totalSeconds % 60
1547
        if hours > 0 {
1548
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1549
        }
1550
        return String(format: "%02d:%02d", minutes, seconds)
1551
    }
1552

            
1553
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1554
        if session.autoStopEnabled == false {
1555
            return "Manual"
1556
        }
1557

            
1558
        if let sessionWarning = sessionWarning(for: session),
1559
           sessionWarning.contains("idle-current") {
1560
            return "Blocked by charger setup"
1561
        }
1562

            
1563
        if session.stopThresholdAmps > 0 {
1564
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1565
        }
1566

            
1567
        return "Learning"
1568
    }
1569

            
1570
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1571
        if session.autoStopEnabled == false {
1572
            return "Manual"
1573
        }
1574
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1575
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1576
        }
1577
        if session.stopThresholdAmps > 0 {
1578
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1579
        }
1580
        return "Learning"
1581
    }
1582

            
1583
    private func shouldShowChargingTransport(
1584
        for session: ChargeSessionSummary,
1585
        chargedDevice: ChargedDeviceSummary
1586
    ) -> Bool {
1587
        chargedDevice.supportedChargingModes.count > 1
1588
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1589
    }
1590

            
1591
    private func shouldShowChargingState(
1592
        for session: ChargeSessionSummary,
1593
        chargedDevice: ChargedDeviceSummary
1594
    ) -> Bool {
1595
        chargedDevice.supportedChargingStateModes.count > 1
1596
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1597
    }
1598

            
1599
    private func batteryColor(for percent: Double) -> Color {
1600
        if percent >= 75 { return .green }
1601
        if percent >= 35 { return .orange }
1602
        return .red
1603
    }
1604

            
1605
    private func etaText(
1606
        rateWhPerSec: Double?,
1607
        remainingWh: Double,
1608
        isRelevant: Bool
1609
    ) -> String? {
1610
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1611
        let seconds = remainingWh / rateWhPerSec
1612
        return seconds > 120 ? formatETA(seconds) : nil
1613
    }
1614

            
1615
    private func etaToTargetText(
1616
        session: ChargeSessionSummary,
1617
        prediction: BatteryLevelPrediction,
1618
        displayedEnergyWh: Double,
1619
        rateWhPerSec: Double?
1620
    ) -> String? {
1621
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1622
            return nil
1623
        }
1624
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1625
        return etaText(
1626
            rateWhPerSec: rateWhPerSec,
1627
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1628
            isRelevant: true
1629
        )
1630
    }
1631

            
1632
    private func formatETA(_ seconds: TimeInterval) -> String {
1633
        let totalMinutes = Int(seconds / 60)
1634
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1635
        let hours = totalMinutes / 60
1636
        let minutes = totalMinutes % 60
1637
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1638
    }
1639

            
1640
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1641
        switch session.status {
1642
        case .active:
1643
            return .red
1644
        case .paused:
1645
            return .orange
1646
        case .completed:
1647
            return .green
1648
        case .abandoned:
1649
            return .secondary
1650
        }
1651
    }
1652

            
1653
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1654
        nil
Bogdan Timofte authored a month ago
1655
    }
1656

            
1657
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1658
        guard session.chargingTransportMode == .wireless else {
1659
            return nil
1660
        }
1661

            
1662
        var components: [String] = []
1663
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1664
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1665
        }
1666
        if session.usesEstimatedWirelessEfficiency {
1667
            components.append("Estimated from wired baseline and checkpoints")
1668
        }
1669
        if session.shouldWarnAboutLowWirelessEfficiency {
1670
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1671
        }
1672

            
1673
        return components.isEmpty ? nil : components.joined(separator: " - ")
1674
    }
1675

            
1676
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1677
        switch session.status {
1678
        case .active:
1679
            return .green
1680
        case .paused:
1681
            return .orange
1682
        case .completed:
1683
            return .teal
1684
        case .abandoned:
1685
            return .secondary
1686
        }
1687
    }
1688
}
1689

            
1690
enum ChargeSessionChartControlMode {
1691
    case none
1692
    case activeMonitoring
1693
    case closed
1694
}
1695

            
1696
struct ChargeSessionChartCardView: View {
1697
    let session: ChargeSessionSummary
1698
    let monitoringMeter: Meter?
1699
    let controlMode: ChargeSessionChartControlMode
1700
    let onSetTrim: (Date?, Date?) -> Void
1701
    let onStopWithTrim: (Date?, Date?) -> Void
1702

            
1703
    @StateObject private var storedMeasurements = Measurements()
1704

            
1705
    private var chartMeasurements: Measurements {
1706
        if let monitoringMeter,
1707
           session.status.isOpen,
1708
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1709
            return monitoringMeter.chargeRecordMeasurements
1710
        }
1711
        return storedMeasurements
1712
    }
1713

            
1714
    private var fullTimeRange: ClosedRange<Date> {
1715
        let start = session.startedAt
1716
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1717
        return start...end
1718
    }
1719

            
1720
    private var fixedTimeRange: ClosedRange<Date>? {
1721
        if monitoringMeter != nil && session.status.isOpen {
1722
            return nil
1723
        }
1724
        return session.effectiveTimeRange
1725
    }
1726

            
1727
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1728
        guard monitoringMeter != nil && session.status.isOpen else {
1729
            return (nil, nil)
1730
        }
1731
        return (session.trimStart, session.trimEnd)
1732
    }
1733

            
1734
    private var showsRangeSelector: Bool {
1735
        controlMode != .none && !session.aggregatedSamples.isEmpty
1736
    }
1737

            
1738
    var body: some View {
1739
        VStack(alignment: .leading, spacing: 12) {
1740
            HStack(spacing: 8) {
1741
                Image(systemName: "chart.xyaxis.line")
1742
                    .foregroundColor(.blue)
1743
                Text("Session Chart")
1744
                    .font(.headline)
1745
                ContextInfoButton(
1746
                    title: "Session Chart",
1747
                    message: chartInfoMessage
1748
                )
1749
                Spacer(minLength: 0)
1750
            }
1751

            
1752
            MeasurementChartView(
1753
                timeRange: fixedTimeRange,
1754
                timeRangeLowerBound: liveTrimBounds.lower,
1755
                timeRangeUpperBound: liveTrimBounds.upper,
1756
                showsRangeSelector: showsRangeSelector,
1757
                rebasesEnergyToVisibleRangeStart: true,
1758
                extendsTimelineToPresent: false,
1759
                showsTemperatureSeries: false,
1760
                rangeSelectorConfiguration: rangeSelectorConfiguration
1761
            )
1762
            .environmentObject(chartMeasurements)
1763
            .frame(maxWidth: .infinity, alignment: .topLeading)
1764
        }
1765
        .padding(18)
1766
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1767
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1768
        .onChange(of: session.id) { _ in
1769
            restoreStoredMeasurementsIfNeeded()
1770
        }
1771
        .onChange(of: session.aggregatedSamples.count) { _ in
1772
            restoreStoredMeasurementsIfNeeded()
1773
        }
1774
    }
1775

            
1776
    private var chartInfoMessage: String {
1777
        if monitoringMeter != nil && session.status.isOpen {
1778
            return "This chart combines the persisted session curve with current live data from this meter."
1779
        }
1780

            
1781
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1782
    }
1783

            
1784
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1785
        switch controlMode {
1786
        case .none:
1787
            return nil
1788
        case .activeMonitoring:
1789
            return MeasurementChartRangeSelectorConfiguration(
1790
                keepAction: MeasurementChartSelectionAction(
1791
                    title: "Trim Start",
1792
                    shortTitle: "Start",
1793
                    systemName: "arrow.right.to.line",
1794
                    tone: .destructive,
1795
                    handler: applyActiveStartTrim
1796
                ),
1797
                removeAction: MeasurementChartSelectionAction(
1798
                    title: "Trim End & Finish",
1799
                    shortTitle: "End",
1800
                    systemName: "arrow.left.to.line",
1801
                    tone: .destructiveProminent,
1802
                    handler: requestActiveEndTrim
1803
                ),
1804
                resetAction: MeasurementChartResetAction(
1805
                    title: "Reset Trim",
1806
                    shortTitle: "Reset",
1807
                    systemName: "arrow.counterclockwise",
1808
                    tone: .reversible,
1809
                    confirmationTitle: "Reset session trim?",
1810
                    confirmationButtonTitle: "Reset trim",
1811
                    handler: {
1812
                        onSetTrim(nil, nil)
1813
                    }
1814
                )
1815
            )
1816
        case .closed:
1817
            return MeasurementChartRangeSelectorConfiguration(
1818
                keepAction: MeasurementChartSelectionAction(
1819
                    title: "Trim Window",
1820
                    shortTitle: "Trim",
1821
                    systemName: "scissors",
1822
                    tone: .destructive,
1823
                    handler: applyClosedTrim
1824
                ),
1825
                removeAction: nil,
1826
                resetAction: MeasurementChartResetAction(
1827
                    title: "Reset Trim",
1828
                    shortTitle: "Reset",
1829
                    systemName: "arrow.counterclockwise",
1830
                    tone: .reversible,
1831
                    confirmationTitle: "Reset session trim?",
1832
                    confirmationButtonTitle: "Reset trim",
1833
                    handler: {
1834
                        onSetTrim(nil, nil)
1835
                    }
1836
                )
1837
            )
1838
        }
1839
    }
1840

            
1841
    private func restoreStoredMeasurementsIfNeeded() {
1842
        guard monitoringMeter == nil || session.status.isOpen == false else {
1843
            return
1844
        }
1845
        storedMeasurements.resetSeries()
1846
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1847
            from: session,
1848
            replacingLiveBufferIfNeeded: true
1849
        )
1850
    }
1851

            
1852
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1853
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1854
    }
1855

            
1856
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1857
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1858
        let end = normalizedEnd(range.upperBound)
1859
        onStopWithTrim(start, end)
1860
    }
1861

            
1862
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1863
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1864
    }
1865

            
1866
    private func normalizedStart(_ date: Date) -> Date? {
1867
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1868
    }
1869

            
1870
    private func normalizedEnd(_ date: Date) -> Date? {
1871
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1872
    }
1873
}
1874

            
1875
private struct ChargeSessionStopRequest: Identifiable {
1876
    let sessionID: UUID
1877
    let title: String
1878
    let confirmTitle: String
1879
    let explanation: String
1880
    let appliesTrim: Bool
1881
    let trimStart: Date?
1882
    let trimEnd: Date?
1883

            
1884
    var id: String {
1885
        [
1886
            sessionID.uuidString,
1887
            title,
1888
            trimStart?.timeIntervalSince1970.description ?? "nil",
1889
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1890
        ].joined(separator: "-")
1891
    }
1892
}
1893

            
1894
private extension View {
1895
    func monitoringActionStyle(tint: Color) -> some View {
1896
        frame(maxWidth: .infinity)
1897
            .padding(.vertical, 10)
1898
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1899
            .buttonStyle(.plain)
1900
    }
1901

            
1902
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1903
        frame(maxWidth: .infinity)
1904
            .padding(.vertical, 9)
1905
            .meterCard(
1906
                tint: tint,
1907
                fillOpacity: isProminent ? 0.22 : 0.10,
1908
                strokeOpacity: isProminent ? 0.32 : 0.14,
1909
                cornerRadius: 14
1910
            )
1911
            .buttonStyle(.plain)
1912
    }
1913
}