Newer Older
1897 lines | 74.923kb
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),
348
            isCollapsible: true
349
        ) {
350
            VStack(alignment: .leading, spacing: 10) {
351
                MeterInfoRowView(label: "Device", value: chargedDevice.name)
352

            
353
                Divider()
354

            
355
                HStack(alignment: .top, spacing: 12) {
356
                    overviewStatCell(label: "Started", value: session.startedAt.format())
357
                    if let endedAt = session.endedAt {
358
                        overviewStatCell(label: "Ended", value: endedAt.format())
359
                    }
360
                }
361

            
362
                HStack(alignment: .top, spacing: 12) {
363
                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
364
                    overviewStatCell(label: "Status", value: session.status.title)
365
                }
366

            
367
                Divider()
368

            
369
                HStack(alignment: .top, spacing: 12) {
370
                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
371
                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
372
                }
373

            
374
                HStack(alignment: .top, spacing: 12) {
375
                    overviewStatCell(label: "Source", value: session.sourceMode.title)
376
                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
377
                }
378

            
379
                if session.isTrimmed {
380
                    Divider()
381
                    HStack(alignment: .top, spacing: 12) {
382
                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
383
                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
384
                    }
385
                }
386

            
387
                let meterLabel: String? = session.meterName ?? session.meterMACAddress
388
                if meterLabel != nil || session.meterModel != nil {
389
                    Divider()
390
                    HStack(alignment: .top, spacing: 12) {
391
                        if let label = meterLabel {
392
                            overviewStatCell(label: "Meter", value: label)
393
                        }
394
                        if let model = session.meterModel {
395
                            overviewStatCell(label: "Meter Model", value: model)
396
                        }
397
                    }
398
                }
Bogdan Timofte authored a month ago
399

            
400
                if session.minimumObservedCurrentAmps != nil
401
                    || session.maximumObservedCurrentAmps != nil
402
                    || session.maximumObservedPowerWatts != nil
403
                    || session.maximumObservedVoltageVolts != nil
404
                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
405
                    || session.completionCurrentAmps != nil
406
                    || session.selectedDataGroup != nil {
407

            
408
                    Divider()
409

            
410
                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
411
                        HStack(alignment: .top, spacing: 12) {
412
                            if let v = session.minimumObservedCurrentAmps {
413
                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
414
                            }
415
                            if let v = session.maximumObservedCurrentAmps {
416
                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
417
                            }
418
                        }
419
                    }
420

            
421
                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
422
                        HStack(alignment: .top, spacing: 12) {
423
                            if let v = session.maximumObservedPowerWatts {
424
                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
425
                            }
426
                            if let v = session.maximumObservedVoltageVolts {
427
                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
428
                            }
429
                        }
430
                    }
431

            
432
                    if session.completionCurrentAmps != nil
433
                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
434
                        HStack(alignment: .top, spacing: 12) {
435
                            if let v = session.completionCurrentAmps {
436
                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
437
                            }
438
                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
439
                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
440
                            }
441
                        }
442
                    }
443

            
444
                    if let dg = session.selectedDataGroup {
445
                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
446
                    }
447
                }
Bogdan Timofte authored a month ago
448
            }
449
        }
450
    }
451

            
Bogdan Timofte authored a month ago
452
    private func overviewStatCell(label: String, value: String) -> some View {
453
        VStack(alignment: .leading, spacing: 2) {
454
            Text(label)
455
                .font(.caption2)
456
                .foregroundColor(.secondary)
457
            Text(value)
458
                .font(.footnote.weight(.medium))
459
                .monospacedDigit()
460
        }
461
        .frame(maxWidth: .infinity, alignment: .leading)
462
    }
463

            
Bogdan Timofte authored a month ago
464
    private func batteryCard(
465
        _ session: ChargeSessionSummary,
466
        chargedDevice: ChargedDeviceSummary
467
    ) -> some View {
468
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
469
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
470
            for: session,
471
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
472
        )
473

            
Bogdan Timofte authored a month ago
474
        let startPercent = session.startBatteryPercent
475
        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
476
        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
477
        let showsPreview = startPercent != nil && endPercent != nil
Bogdan Timofte authored a month ago
478

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

            
481
            // Header — always visible, tappable
482
            HStack(spacing: 8) {
483
                Text("Battery")
484
                    .font(.headline)
485
                Spacer(minLength: 0)
486
                Image(systemName: "chevron.up")
487
                    .font(.caption.weight(.semibold))
488
                    .foregroundColor(.secondary)
489
                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
490
                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
491
            }
492
            .contentShape(Rectangle())
493
            .onTapGesture {
494
                withAnimation(.easeInOut(duration: 0.25)) {
495
                    isBatteryCardExpanded.toggle()
Bogdan Timofte authored a month ago
496
                }
Bogdan Timofte authored a month ago
497
            }
Bogdan Timofte authored a month ago
498

            
Bogdan Timofte authored a month ago
499
            // Preview bar — always visible when there is enough data
500
            if showsPreview, let start = startPercent, let end = endPercent {
501
                batteryPreviewBar(
502
                    startPercent: start,
503
                    endPercent: end,
504
                    checkpoints: session.checkpoints,
505
                    isEstimatedEnd: isEstimatedEnd
506
                )
507
                .padding(.top, 10)
508
            }
509

            
510
            // Collapsible detail
511
            if isBatteryCardExpanded {
512
                VStack(alignment: .leading, spacing: 10) {
513

            
514
                    // Energy
Bogdan Timofte authored a month ago
515
                    HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
516
                        overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
517
                        if let capacityEstimateWh = session.capacityEstimateWh {
518
                            overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
519
                        }
520
                    }
Bogdan Timofte authored a month ago
521
                    if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
522
                        MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
523
                    }
524
                    if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
525
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
526
                        MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
527
                    }
528
                    if let chargerID = session.chargerID,
529
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
530
                        MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
531
                    }
532
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
533
                        Text(wirelessSessionHint)
534
                            .font(.caption2)
535
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
536
                    }
537
                    if let sessionWarning = sessionWarning(for: session) {
538
                        Label(sessionWarning, systemImage: "exclamationmark.triangle")
539
                            .font(.caption2)
540
                            .foregroundColor(.orange)
541
                    }
542

            
543
                    // Battery percentages
544
                    if startPercent != nil || session.endBatteryPercent != nil {
545
                        Divider()
Bogdan Timofte authored a month ago
546
                        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
547
                            if let v = startPercent {
548
                                overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
549
                            }
Bogdan Timofte authored a month ago
550
                            if let v = session.endBatteryPercent {
551
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
552
                            }
553
                        }
Bogdan Timofte authored a month ago
554
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
555
                            HStack(alignment: .top, spacing: 12) {
556
                                if let v = session.batteryDeltaPercent {
557
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
558
                                }
559
                                if let v = session.targetBatteryPercent {
560
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
561
                                }
562
                            }
563
                        }
564
                        if let batteryPrediction {
565
                            HStack(alignment: .top, spacing: 12) {
566
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
567
                            }
568
                            Text(
569
                                "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
570
                            )
571
                            .font(.caption2)
572
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
573
                        }
574
                    }
Bogdan Timofte authored a month ago
575

            
576
                    // Checkpoints
577
                    Divider()
578
                    BatteryCheckpointSectionView(
579
                        sessionID: session.id,
580
                        checkpoints: session.checkpoints,
581
                        message: session.status.isOpen
582
                            ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
583
                            : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
584
                        canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
585
                        canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
586
                        requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
587
                        effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
588
                        onDelete: { checkpoint in
589
                            pendingCheckpointDeletion = checkpoint
590
                        }
591
                    )
Bogdan Timofte authored a month ago
592
                }
Bogdan Timofte authored a month ago
593
                .padding(.top, 12)
594
                .transition(.opacity.combined(with: .move(edge: .top)))
595
            }
596
        }
597
        .frame(maxWidth: .infinity, alignment: .leading)
598
        .padding(18)
599
        .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
600
    }
Bogdan Timofte authored a month ago
601

            
Bogdan Timofte authored a month ago
602
    private func batteryPreviewBar(
603
        startPercent: Double,
604
        endPercent: Double,
605
        checkpoints: [ChargeCheckpointSummary],
606
        isEstimatedEnd: Bool
607
    ) -> some View {
608
        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
609
        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
610
        let color = batteryColor(for: endPercent)
611

            
612
        return HStack(spacing: 6) {
613
            Text("\(Int(startPercent.rounded()))%")
614
                .font(.caption2.weight(.semibold))
615
                .foregroundColor(.secondary)
616
                .monospacedDigit()
617
                .frame(minWidth: 26, alignment: .trailing)
618

            
619
            GeometryReader { geo in
620
                let w = geo.size.width
621
                ZStack(alignment: .leading) {
622
                    Capsule()
623
                        .fill(Color.primary.opacity(0.10))
624

            
625
                    Rectangle()
626
                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
627
                        .frame(width: max(w * (endFrac - startFrac), 3))
628
                        .offset(x: w * startFrac)
629

            
630
                    ForEach(checkpoints, id: \.id) { cp in
631
                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
632
                        let isFinal = cp.flag == .final
633
                        Rectangle()
634
                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
635
                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
636
                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
Bogdan Timofte authored a month ago
637
                    }
Bogdan Timofte authored a month ago
638
                }
639
                .clipShape(Capsule())
640
            }
641
            .frame(height: 8)
642

            
643
            HStack(spacing: 1) {
644
                if isEstimatedEnd {
645
                    Text("~")
646
                        .font(.caption2)
647
                        .foregroundColor(.secondary)
648
                }
649
                Text("\(Int(endPercent.rounded()))%")
650
                    .font(.caption2.weight(.semibold))
651
                    .foregroundColor(isEstimatedEnd ? .secondary : color)
652
                    .monospacedDigit()
Bogdan Timofte authored a month ago
653
            }
Bogdan Timofte authored a month ago
654
            .frame(minWidth: 32, alignment: .leading)
Bogdan Timofte authored a month ago
655
        }
656
    }
657

            
658
    private func batteryGaugeSection(
659
        prediction: BatteryLevelPrediction,
660
        session: ChargeSessionSummary,
661
        displayedEnergyWh: Double
662
    ) -> some View {
663
        let percent = prediction.predictedPercent
664
        let color = batteryColor(for: percent)
665
        let duration = displayedSessionDuration(for: session)
666
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
667
            ? displayedEnergyWh / duration
668
            : nil
669
        let etaToFull = etaText(
670
            rateWhPerSec: rateWhPerSec,
671
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
672
            isRelevant: percent < 98
673
        )
674
        let etaToTarget = etaToTargetText(
675
            session: session,
676
            prediction: prediction,
677
            displayedEnergyWh: displayedEnergyWh,
678
            rateWhPerSec: rateWhPerSec
679
        )
680

            
681
        return VStack(spacing: 10) {
682
            HStack(alignment: .lastTextBaseline, spacing: 8) {
683
                HStack(alignment: .lastTextBaseline, spacing: 3) {
684
                    Text("\(Int(percent.rounded()))")
685
                        .font(.system(size: 52, weight: .bold, design: .rounded))
686
                        .foregroundColor(color)
687
                        .monospacedDigit()
688
                    Text("%")
689
                        .font(.title2.weight(.semibold))
690
                        .foregroundColor(color.opacity(0.8))
691
                }
692

            
693
                Spacer()
694

            
695
                VStack(alignment: .trailing, spacing: 2) {
696
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
697
                        .font(.callout.weight(.bold))
698
                        .foregroundColor(.orange)
699
                        .monospacedDigit()
700
                    Text("est. capacity")
701
                        .font(.caption2)
702
                        .foregroundColor(.secondary)
703
                }
704
            }
705

            
706
            batteryProgressBar(
707
                percent: percent,
708
                startPercent: session.startBatteryPercent,
709
                targetPercent: session.targetBatteryPercent
710
            )
711

            
712
            HStack(spacing: 14) {
713
                if let etaToFull {
714
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
715
                }
716

            
717
                if let etaToTarget, let target = session.targetBatteryPercent {
718
                    etaPill(
719
                        icon: "bell.badge.fill",
720
                        tint: .indigo,
721
                        value: etaToTarget,
722
                        label: "to \(Int(target.rounded()))%"
723
                    )
724
                }
725

            
726
                Spacer()
727

            
728
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
729
                    .font(.caption2)
730
                    .foregroundColor(.secondary)
731
                    .multilineTextAlignment(.trailing)
732
            }
733
        }
734
        .padding(14)
735
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
736
    }
737

            
738
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
739
        VStack(alignment: .leading, spacing: 1) {
740
            HStack(spacing: 4) {
741
                Image(systemName: icon)
742
                    .font(.caption)
743
                    .foregroundColor(tint)
744
                Text(value)
745
                    .font(.caption.weight(.bold))
746
            }
747
            Text(label)
748
                .font(.caption2)
749
                .foregroundColor(.secondary)
750
        }
751
    }
752

            
753
    private func batteryProgressBar(
754
        percent: Double,
755
        startPercent: Double?,
756
        targetPercent: Double?
757
    ) -> some View {
758
        let color = batteryColor(for: percent)
759
        return GeometryReader { geo in
760
            let width = geo.size.width
761
            ZStack(alignment: .leading) {
762
                Capsule()
763
                    .fill(Color.primary.opacity(0.10))
764
                Rectangle()
765
                    .fill(
766
                        LinearGradient(
767
                            colors: [color.opacity(0.6), color],
768
                            startPoint: .leading,
769
                            endPoint: .trailing
770
                        )
771
                    )
772
                    .frame(width: max(width * CGFloat(percent / 100), 4))
773
                    .animation(.easeInOut(duration: 0.4), value: percent)
774
                if let start = startPercent, start > 2, start < 98 {
775
                    Rectangle()
776
                        .fill(Color.white.opacity(0.55))
777
                        .frame(width: 2, height: 20)
778
                        .offset(x: width * CGFloat(start / 100) - 1)
779
                }
780
                if let target = targetPercent {
781
                    Rectangle()
782
                        .fill(Color.indigo.opacity(0.9))
783
                        .frame(width: 2.5, height: 20)
784
                        .offset(x: width * CGFloat(target / 100) - 1.25)
785
                }
786
            }
787
            .clipShape(Capsule())
788
        }
789
        .frame(height: 20)
790
    }
791

            
792
    private func sessionMetricsGrid(
793
        session: ChargeSessionSummary,
794
        chargedDevice: ChargedDeviceSummary,
795
        displayedEnergyWh: Double,
796
        hasPrediction: Bool
797
    ) -> some View {
798
        let capacityFallback: Double? = hasPrediction ? nil : (
799
            session.capacityEstimateWh
800
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
801
                ?? chargedDevice.estimatedBatteryCapacityWh
802
        )
803
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
804

            
805
        return LazyVGrid(columns: columns, spacing: 8) {
806
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
807
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
808

            
809
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
810
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
811
            }
812
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
813
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
814
            }
815

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

            
818
            if let capacityFallback {
819
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
820
            }
821
        }
822
    }
823

            
824
    private func metricCell(label: String, value: String, tint: Color) -> some View {
825
        VStack(alignment: .leading, spacing: 3) {
826
            Text(label)
827
                .font(.caption2)
828
                .foregroundColor(.secondary)
829
            Text(value)
830
                .font(.subheadline.weight(.semibold))
831
                .lineLimit(1)
832
                .minimumScaleFactor(0.7)
833
                .monospacedDigit()
834
        }
835
        .frame(maxWidth: .infinity, alignment: .leading)
836
        .padding(.horizontal, 12)
837
        .padding(.vertical, 10)
838
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
839
    }
840

            
841
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
842
        VStack(alignment: .leading, spacing: 10) {
843
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
844
                .font(.subheadline.weight(.semibold))
845

            
846
            if let contradictionPercent = session.completionContradictionPercent {
847
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
848
                    .font(.caption)
849
                    .foregroundColor(.secondary)
850
            } else {
851
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
852
                    .font(.caption)
853
                    .foregroundColor(.secondary)
854
            }
855

            
856
            HStack(spacing: 10) {
857
                Button("Finish") {
858
                    beginStopConfirmation(for: session)
859
                }
860
                .frame(maxWidth: .infinity)
861
                .padding(.vertical, 9)
862
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
863
                .buttonStyle(.plain)
864

            
865
                Button("Keep Monitoring") {
866
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
867
                }
868
                .frame(maxWidth: .infinity)
869
                .padding(.vertical, 9)
870
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
871
                .buttonStyle(.plain)
872
            }
873
        }
874
        .padding(14)
875
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
876
    }
877

            
878
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
879
        let draftBelowPrediction: Bool = {
880
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
881
            return draft <= predictedPercent
882
        }()
883
        let savedBelowPrediction: Bool = {
884
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
885
            return saved <= predictedPercent
886
        }()
887

            
888
        return HStack(alignment: .center, spacing: 8) {
889
            Image(systemName: "bell.badge")
890
                .foregroundColor(.indigo)
891
                .font(.subheadline)
892

            
893
            Text("Notify at")
894
                .font(.subheadline.weight(.semibold))
895

            
896
            Spacer(minLength: 8)
897

            
898
            if showingInlineTargetEditor {
899
                targetEditorControls(
900
                    session: session,
901
                    draftBelowPrediction: draftBelowPrediction,
902
                    predictedPercent: predictedPercent
903
                )
904
            } else {
905
                savedTargetControls(
906
                    session: session,
907
                    savedBelowPrediction: savedBelowPrediction,
908
                    predictedPercent: predictedPercent
909
                )
910
            }
911
        }
912
    }
913

            
914
    private func targetEditorControls(
915
        session: ChargeSessionSummary,
916
        draftBelowPrediction: Bool,
917
        predictedPercent: Double?
918
    ) -> some View {
919
        Group {
920
            Button {
921
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
922
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
923
            } label: {
924
                Image(systemName: "minus.circle")
925
                    .font(.title3)
926
            }
927
            .buttonStyle(.plain)
928

            
929
            TextField("-", text: $draftTargetText)
930
                .keyboardType(.decimalPad)
931
                .textFieldStyle(.roundedBorder)
932
                .frame(width: 48)
933
                .multilineTextAlignment(.center)
934
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
935

            
936
            Text("%")
937
                .font(.subheadline)
938
                .foregroundColor(.secondary)
939

            
940
            if draftBelowPrediction, let predictedPercent {
941
                predictionWarningButton(predictedPercent: predictedPercent)
942
            }
943

            
944
            Button {
945
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
946
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
947
            } label: {
948
                Image(systemName: "plus.circle")
949
                    .font(.title3)
950
            }
951
            .buttonStyle(.plain)
952

            
953
            Button {
954
                if let value = parsedDraftTarget {
955
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
956
                }
957
                showingInlineTargetEditor = false
958
            } label: {
959
                Image(systemName: "checkmark.circle.fill")
960
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
961
                    .font(.title3)
962
            }
963
            .buttonStyle(.plain)
964
            .disabled(parsedDraftTarget == nil)
965

            
966
            Button {
967
                showingInlineTargetEditor = false
968
                draftTargetText = ""
969
            } label: {
970
                Image(systemName: "xmark.circle")
971
                    .foregroundColor(.secondary)
972
                    .font(.title3)
973
            }
974
            .buttonStyle(.plain)
975
        }
976
    }
977

            
978
    private func savedTargetControls(
979
        session: ChargeSessionSummary,
980
        savedBelowPrediction: Bool,
981
        predictedPercent: Double?
982
    ) -> some View {
983
        Group {
984
            if let targetPercent = session.targetBatteryPercent {
985
                Text("\(targetPercent.format(decimalDigits: 0))%")
986
                    .font(.subheadline.weight(.semibold))
987
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
988

            
989
                if savedBelowPrediction, let predictedPercent {
990
                    predictionWarningButton(predictedPercent: predictedPercent)
991
                }
992

            
993
                Button {
994
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
995
                } label: {
996
                    Image(systemName: "xmark.circle.fill")
997
                        .foregroundColor(.secondary)
998
                        .font(.callout)
999
                }
1000
                .buttonStyle(.plain)
1001
                .help("Remove alert")
1002
            }
1003

            
1004
            Button {
1005
                draftTargetText = session.targetBatteryPercent.map {
1006
                    $0.format(decimalDigits: 0)
1007
                } ?? "80"
1008
                showingInlineTargetEditor = true
1009
            } label: {
1010
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1011
                    .font(.caption.weight(.semibold))
1012
                    .frame(width: 30, height: 30)
1013
                    .contentShape(Rectangle())
1014
            }
1015
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1016
            .buttonStyle(.plain)
1017
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1018
        }
1019
    }
1020

            
1021
    private func predictionWarningButton(predictedPercent: Double) -> some View {
1022
        Button {} label: {
1023
            Image(systemName: "exclamationmark.triangle.fill")
1024
                .font(.callout.weight(.semibold))
1025
                .foregroundColor(.orange)
1026
        }
1027
        .buttonStyle(.plain)
1028
        .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.")
1029
    }
1030

            
1031
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
1032
        HStack(spacing: 10) {
1033
            if session.status == .active {
1034
                Button("Pause") {
1035
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1036
                }
1037
                .monitoringActionStyle(tint: .orange)
1038
            } else if session.status == .paused {
1039
                Button("Resume") {
1040
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1041
                }
1042
                .monitoringActionStyle(tint: .blue)
1043
            }
1044

            
1045
            Button("Terminate Session") {
1046
                beginStopConfirmation(for: session)
1047
            }
1048
            .monitoringActionStyle(tint: .red)
1049
        }
1050
    }
1051

            
1052
    private func stopConfirmPanel(
1053
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1054
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1055
    ) -> some View {
1056
        let canSave = hasSavableChargeData(
1057
            session: session,
Bogdan Timofte authored a month ago
1058
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1059
        )
1060
        let saveDisabledReason = saveDisabledReason(
1061
            session: session,
Bogdan Timofte authored a month ago
1062
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1063
        )
1064
        let isSaveEnabled = saveDisabledReason == nil
1065

            
1066
        return VStack(alignment: .leading, spacing: 12) {
1067
            HStack {
1068
                Text("Final Checkpoint")
1069
                    .font(.subheadline.weight(.semibold))
1070
                Text("optional")
1071
                    .font(.caption2.weight(.semibold))
1072
                    .foregroundColor(.secondary)
1073
            }
1074

            
1075
            finalCheckpointPicker(session)
1076

            
1077
            if finalCheckpointMode == .custom {
1078
                customFinalCheckpointRow
1079
            }
1080

            
1081
            if let saveDisabledReason {
1082
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
1083
                    .font(.caption)
1084
                    .foregroundColor(.red)
1085
                    .fixedSize(horizontal: false, vertical: true)
1086
            } else if let stopFailureMessage {
1087
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
1088
                    .font(.caption)
1089
                    .foregroundColor(.red)
1090
                    .fixedSize(horizontal: false, vertical: true)
1091
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
1092
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
1093
                    .font(.caption)
1094
                    .foregroundColor(.green)
1095
                    .fixedSize(horizontal: false, vertical: true)
1096
            }
1097

            
1098
            HStack(spacing: 8) {
1099
                Button("Discard") {
1100
                    discardSession(session)
1101
                }
1102
                .monitoringPanelActionStyle(tint: .secondary)
1103

            
1104
                Button {
1105
                    stopSession(
1106
                        session,
Bogdan Timofte authored a month ago
1107
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1108
                    )
1109
                } label: {
1110
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1111
                        .frame(maxWidth: .infinity)
1112
                }
1113
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1114
                .disabled(!isSaveEnabled)
1115
                .help(saveDisabledReason ?? "Close and save this session")
1116

            
1117
                Button("Cancel") {
1118
                    resetStopConfirmation()
1119
                }
1120
                .monitoringPanelActionStyle(tint: .secondary)
1121
            }
1122
        }
1123
        .padding(14)
1124
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1125
    }
1126

            
1127
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1128
        return HStack(spacing: 8) {
1129
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1130
                Button {
1131
                    finalCheckpointMode = mode
1132
                    if mode == .custom {
1133
                        prefillFinalCheckpointIfNeeded(for: session)
1134
                    } else {
1135
                        finalCheckpointText = ""
1136
                    }
1137
                } label: {
1138
                    VStack(spacing: 5) {
1139
                        Image(systemName: mode.icon)
1140
                            .font(.title3)
1141
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1142
                        Text(mode.label)
1143
                            .font(.caption.weight(.semibold))
1144
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1145
                    }
1146
                    .frame(maxWidth: .infinity)
1147
                    .padding(.vertical, 10)
1148
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1149
                    .meterCard(
1150
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1151
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1152
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1153
                        cornerRadius: 12
1154
                    )
1155
                }
1156
                .buttonStyle(.plain)
1157
            }
1158
        }
1159
    }
1160

            
1161
    private var customFinalCheckpointRow: some View {
1162
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1163
            || parsedFinalCheckpoint == nil
1164

            
1165
        return HStack(spacing: 8) {
1166
            Button {
1167
                adjustFinalCheckpoint(by: -1)
1168
            } label: {
1169
                Image(systemName: "minus.circle").font(.title3)
1170
            }
1171
            .buttonStyle(.plain)
1172

            
1173
            TextField("-", text: $finalCheckpointText)
1174
                .keyboardType(.decimalPad)
1175
                .textFieldStyle(.roundedBorder)
1176
                .frame(width: 56)
1177
                .multilineTextAlignment(.center)
1178
                .overlay(
1179
                    RoundedRectangle(cornerRadius: 6)
1180
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1181
                )
1182

            
1183
            Text("%").foregroundColor(.secondary)
1184

            
1185
            Text("required")
1186
                .font(.caption2.weight(.semibold))
1187
                .foregroundColor(isInvalid ? .red : .secondary)
1188

            
1189
            Button {
1190
                adjustFinalCheckpoint(by: 1)
1191
            } label: {
1192
                Image(systemName: "plus.circle").font(.title3)
1193
            }
1194
            .buttonStyle(.plain)
1195

            
1196
            Spacer()
1197
        }
1198
    }
1199

            
1200
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1201
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1202
            if let meterName = session.meterName {
1203
                MeterInfoRowView(label: "Controlled On", value: meterName)
1204
            }
1205
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1206
                .font(.caption2)
1207
                .foregroundColor(.secondary)
1208
        }
1209
    }
1210

            
1211
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1212
        MeterInfoCardView(title: "Administration", tint: .red) {
1213
            Button(role: .destructive) {
1214
                pendingSessionDeletion = session
1215
            } label: {
1216
                Label("Delete Session", systemImage: "trash")
1217
                    .font(.subheadline.weight(.semibold))
1218
                    .frame(maxWidth: .infinity)
1219
                    .padding(.vertical, 10)
1220
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1221
            }
1222
            .buttonStyle(.plain)
1223
        }
1224
    }
1225

            
1226
    @ViewBuilder
1227
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1228
        if let window = detectedTrimWindow {
1229
            HStack(spacing: 12) {
1230
                Image(systemName: "scissors.circle.fill")
1231
                    .font(.title3)
1232
                    .foregroundColor(.blue)
1233

            
1234
                VStack(alignment: .leading, spacing: 2) {
1235
                    Text("Charging ended early")
1236
                        .font(.subheadline.weight(.semibold))
1237
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1238
                        .font(.caption)
1239
                        .foregroundColor(.secondary)
1240
                        .fixedSize(horizontal: false, vertical: true)
1241
                }
1242

            
1243
                Spacer(minLength: 0)
1244

            
1245
                VStack(spacing: 6) {
1246
                    Button("Trim Start") {
1247
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1248
                        trimBannerDismissedForSessionID = session.id
1249
                    }
1250
                    .font(.caption.weight(.semibold))
1251
                    .buttonStyle(.borderedProminent)
1252
                    .controlSize(.small)
1253
                    .tint(.blue)
1254

            
1255
                    Button("End & Finish") {
1256
                        requestStop(
1257
                            session,
1258
                            applyingTrimStart: session.trimStart ?? window.start,
1259
                            trimEnd: window.end,
1260
                            title: "Trim End & Finish",
1261
                            confirmTitle: "Finish",
1262
                            explanation: "The detected charging window will be saved before the session is closed."
1263
                        )
1264
                        trimBannerDismissedForSessionID = session.id
1265
                    }
1266
                    .font(.caption.weight(.semibold))
1267
                    .buttonStyle(.bordered)
1268
                    .controlSize(.small)
1269
                    .tint(.red)
1270
                }
1271
            }
1272
            .padding(14)
1273
            .background(
1274
                RoundedRectangle(cornerRadius: 14)
1275
                    .fill(Color.blue.opacity(0.10))
1276
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1277
            )
1278
            .transition(.opacity.combined(with: .move(edge: .top)))
1279
        }
1280
    }
1281

            
1282
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1283
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1284
    }
1285

            
1286
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1287
        ChargeSessionChartCardView(
1288
            session: session,
1289
            monitoringMeter: liveMonitoringMeter,
1290
            controlMode: chartControlMode(for: session),
1291
            onSetTrim: { start, end in
1292
                setSessionTrim(sessionID: session.id, start: start, end: end)
1293
            },
1294
            onStopWithTrim: { start, end in
1295
                requestStop(
1296
                    session,
1297
                    applyingTrimStart: start,
1298
                    trimEnd: end,
1299
                    title: "Trim End & Finish",
1300
                    confirmTitle: "Finish",
1301
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1302
                )
1303
            }
1304
        )
1305
    }
1306

            
1307
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1308
        if hasMonitoringControls {
1309
            return .activeMonitoring
1310
        }
1311

            
1312
        if session.status.isOpen == false {
1313
            return .closed
1314
        }
1315

            
1316
        return .none
1317
    }
1318

            
1319
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1320
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1321
        trimBannerDismissedForSessionID = sessionID
1322
    }
1323

            
1324
    private func requestStop(
1325
        _ session: ChargeSessionSummary,
1326
        applyingTrimStart trimStart: Date?,
1327
        trimEnd: Date?,
1328
        title: String,
1329
        confirmTitle: String,
1330
        explanation: String
1331
    ) {
1332
        pendingSessionStopRequest = ChargeSessionStopRequest(
1333
            sessionID: session.id,
1334
            title: title,
1335
            confirmTitle: confirmTitle,
1336
            explanation: explanation,
1337
            appliesTrim: trimStart != nil || trimEnd != nil,
1338
            trimStart: trimStart,
1339
            trimEnd: trimEnd
1340
        )
1341
    }
1342

            
1343
    private var parsedDraftTarget: Double? {
1344
        let normalized = draftTargetText
1345
            .trimmingCharacters(in: .whitespacesAndNewlines)
1346
            .replacingOccurrences(of: ",", with: ".")
1347
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1348
        return value
1349
    }
1350

            
1351
    private var parsedFinalCheckpoint: Double? {
1352
        let normalized = finalCheckpointText
1353
            .trimmingCharacters(in: .whitespacesAndNewlines)
1354
            .replacingOccurrences(of: ",", with: ".")
1355
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1356
        return value
1357
    }
1358

            
1359
    private var resolvedFinalCheckpoint: Double? {
1360
        switch finalCheckpointMode {
1361
        case .full:   return 100
1362
        case .skip:   return nil
1363
        case .custom: return parsedFinalCheckpoint
1364
        }
1365
    }
1366

            
1367
    private func adjustFinalCheckpoint(by delta: Double) {
1368
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1369
        let next = min(max(current + delta, 0), 100)
1370
        finalCheckpointText = next.format(decimalDigits: 0)
1371
    }
1372

            
1373
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1374
        guard let session else { return nil }
1375
        if let endBatteryPercent = session.endBatteryPercent {
1376
            return endBatteryPercent
1377
        }
1378
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1379
            return latestCheckpoint.batteryPercent
1380
        }
1381
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1382
    }
1383

            
1384
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1385
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1386
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1387
            return
1388
        }
1389
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1390
    }
1391

            
1392
    private func hasSavableChargeData(
1393
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1394
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1395
    ) -> Bool {
1396
        session.hasSavableChargeData
1397
            || displayedEnergyWh > 0
1398
    }
1399

            
1400
    private func saveDisabledReason(
1401
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1402
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1403
    ) -> String? {
1404
        if finalCheckpointMode == .custom {
1405
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1406
            if trimmed.isEmpty {
1407
                return "Enter the final battery percentage or choose Skip."
1408
            }
1409
            if parsedFinalCheckpoint == nil {
1410
                return "Final battery percentage must be between 0 and 100."
1411
            }
1412
        }
1413

            
1414
        guard hasSavableChargeData(
1415
            session: session,
Bogdan Timofte authored a month ago
1416
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1417
        ) else {
1418
            return "This session has no charging data to save. Discard it instead."
1419
        }
1420

            
1421
        return nil
1422
    }
1423

            
1424
    private func stopSession(
1425
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1426
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1427
    ) {
1428
        stopFailureMessage = nil
1429

            
1430
        if let saveDisabledReason = saveDisabledReason(
1431
            session: session,
Bogdan Timofte authored a month ago
1432
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1433
        ) {
1434
            stopFailureMessage = saveDisabledReason
1435
            return
1436
        }
1437

            
1438
        let didSave = appData.stopChargeSession(
1439
            sessionID: session.id,
1440
            finalBatteryPercent: resolvedFinalCheckpoint,
1441
            from: liveMonitoringMeter
1442
        )
1443
        if didSave {
1444
            resetStopConfirmation()
1445
        } else {
1446
            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."
1447
        }
1448
    }
1449

            
1450
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1451
        finalCheckpointMode = .skip
1452
        finalCheckpointText = ""
1453
        stopFailureMessage = nil
1454
        showingStopConfirm = true
1455
    }
1456

            
1457
    private func discardSession(_ session: ChargeSessionSummary) {
1458
        _ = appData.deleteChargeSession(sessionID: session.id)
1459
        resetStopConfirmation()
1460
    }
1461

            
1462
    private func resetStopConfirmation() {
1463
        showingStopConfirm = false
1464
        finalCheckpointText = ""
1465
        finalCheckpointMode = .skip
1466
        stopFailureMessage = nil
1467
    }
1468

            
1469
    private func syncMonitoringRestore() {
1470
        guard let session,
1471
              session.status.isOpen,
1472
              let liveMonitoringMeter,
1473
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1474
            return
1475
        }
1476
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1477
    }
1478

            
1479
    private func runTrimDetection() {
1480
        guard hasMonitoringControls,
1481
              let session,
1482
              session.isTrimmed == false,
1483
              !session.aggregatedSamples.isEmpty else {
1484
            detectedTrimWindow = nil
1485
            return
1486
        }
1487

            
1488
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1489
        detectedTrimWindow = ChargingWindowDetector.detect(
1490
            samples: session.aggregatedSamples,
1491
            sessionStart: session.startedAt,
1492
            sessionEnd: sessionEnd
1493
        )
1494
    }
1495

            
1496
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1497
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1498
        guard session.isTrimmed == false else { return storedEnergyWh }
1499
        guard session.status.isOpen else { return storedEnergyWh }
1500
        guard let liveMonitoringMeter else { return storedEnergyWh }
1501
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1502
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1503
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1504
        }
1505
        return storedEnergyWh
1506
    }
1507

            
1508
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1509
        let storedDuration = max(session.effectiveDuration, 0)
1510
        guard session.isTrimmed == false else { return storedDuration }
1511
        guard session.status.isOpen else { return storedDuration }
1512
        guard let liveMonitoringMeter else { return storedDuration }
1513
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1514
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1515
    }
1516

            
1517
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1518
        let displayedDuration = displayedSessionDuration(for: session)
1519
        let formatter = DateComponentsFormatter()
1520
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1521
        formatter.unitsStyle = .abbreviated
1522
        formatter.zeroFormattingBehavior = .dropAll
1523
        return formatter.string(from: displayedDuration) ?? "0m"
1524
    }
1525

            
1526
    private func formatDuration(_ duration: TimeInterval) -> String {
1527
        let totalSeconds = Int(duration.rounded(.down))
1528
        let hours = totalSeconds / 3600
1529
        let minutes = (totalSeconds % 3600) / 60
1530
        let seconds = totalSeconds % 60
1531
        if hours > 0 {
1532
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1533
        }
1534
        return String(format: "%02d:%02d", minutes, seconds)
1535
    }
1536

            
1537
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1538
        if session.autoStopEnabled == false {
1539
            return "Manual"
1540
        }
1541

            
1542
        if let sessionWarning = sessionWarning(for: session),
1543
           sessionWarning.contains("idle-current") {
1544
            return "Blocked by charger setup"
1545
        }
1546

            
1547
        if session.stopThresholdAmps > 0 {
1548
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1549
        }
1550

            
1551
        return "Learning"
1552
    }
1553

            
1554
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1555
        if session.autoStopEnabled == false {
1556
            return "Manual"
1557
        }
1558
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1559
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1560
        }
1561
        if session.stopThresholdAmps > 0 {
1562
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1563
        }
1564
        return "Learning"
1565
    }
1566

            
1567
    private func shouldShowChargingTransport(
1568
        for session: ChargeSessionSummary,
1569
        chargedDevice: ChargedDeviceSummary
1570
    ) -> Bool {
1571
        chargedDevice.supportedChargingModes.count > 1
1572
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1573
    }
1574

            
1575
    private func shouldShowChargingState(
1576
        for session: ChargeSessionSummary,
1577
        chargedDevice: ChargedDeviceSummary
1578
    ) -> Bool {
1579
        chargedDevice.supportedChargingStateModes.count > 1
1580
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1581
    }
1582

            
1583
    private func batteryColor(for percent: Double) -> Color {
1584
        if percent >= 75 { return .green }
1585
        if percent >= 35 { return .orange }
1586
        return .red
1587
    }
1588

            
1589
    private func etaText(
1590
        rateWhPerSec: Double?,
1591
        remainingWh: Double,
1592
        isRelevant: Bool
1593
    ) -> String? {
1594
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1595
        let seconds = remainingWh / rateWhPerSec
1596
        return seconds > 120 ? formatETA(seconds) : nil
1597
    }
1598

            
1599
    private func etaToTargetText(
1600
        session: ChargeSessionSummary,
1601
        prediction: BatteryLevelPrediction,
1602
        displayedEnergyWh: Double,
1603
        rateWhPerSec: Double?
1604
    ) -> String? {
1605
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1606
            return nil
1607
        }
1608
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1609
        return etaText(
1610
            rateWhPerSec: rateWhPerSec,
1611
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1612
            isRelevant: true
1613
        )
1614
    }
1615

            
1616
    private func formatETA(_ seconds: TimeInterval) -> String {
1617
        let totalMinutes = Int(seconds / 60)
1618
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1619
        let hours = totalMinutes / 60
1620
        let minutes = totalMinutes % 60
1621
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1622
    }
1623

            
1624
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1625
        switch session.status {
1626
        case .active:
1627
            return .red
1628
        case .paused:
1629
            return .orange
1630
        case .completed:
1631
            return .green
1632
        case .abandoned:
1633
            return .secondary
1634
        }
1635
    }
1636

            
1637
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1638
        nil
Bogdan Timofte authored a month ago
1639
    }
1640

            
1641
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1642
        guard session.chargingTransportMode == .wireless else {
1643
            return nil
1644
        }
1645

            
1646
        var components: [String] = []
1647
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1648
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1649
        }
1650
        if session.usesEstimatedWirelessEfficiency {
1651
            components.append("Estimated from wired baseline and checkpoints")
1652
        }
1653
        if session.shouldWarnAboutLowWirelessEfficiency {
1654
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1655
        }
1656

            
1657
        return components.isEmpty ? nil : components.joined(separator: " - ")
1658
    }
1659

            
1660
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1661
        switch session.status {
1662
        case .active:
1663
            return .green
1664
        case .paused:
1665
            return .orange
1666
        case .completed:
1667
            return .teal
1668
        case .abandoned:
1669
            return .secondary
1670
        }
1671
    }
1672
}
1673

            
1674
enum ChargeSessionChartControlMode {
1675
    case none
1676
    case activeMonitoring
1677
    case closed
1678
}
1679

            
1680
struct ChargeSessionChartCardView: View {
1681
    let session: ChargeSessionSummary
1682
    let monitoringMeter: Meter?
1683
    let controlMode: ChargeSessionChartControlMode
1684
    let onSetTrim: (Date?, Date?) -> Void
1685
    let onStopWithTrim: (Date?, Date?) -> Void
1686

            
1687
    @StateObject private var storedMeasurements = Measurements()
1688

            
1689
    private var chartMeasurements: Measurements {
1690
        if let monitoringMeter,
1691
           session.status.isOpen,
1692
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1693
            return monitoringMeter.chargeRecordMeasurements
1694
        }
1695
        return storedMeasurements
1696
    }
1697

            
1698
    private var fullTimeRange: ClosedRange<Date> {
1699
        let start = session.startedAt
1700
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1701
        return start...end
1702
    }
1703

            
1704
    private var fixedTimeRange: ClosedRange<Date>? {
1705
        if monitoringMeter != nil && session.status.isOpen {
1706
            return nil
1707
        }
1708
        return session.effectiveTimeRange
1709
    }
1710

            
1711
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1712
        guard monitoringMeter != nil && session.status.isOpen else {
1713
            return (nil, nil)
1714
        }
1715
        return (session.trimStart, session.trimEnd)
1716
    }
1717

            
1718
    private var showsRangeSelector: Bool {
1719
        controlMode != .none && !session.aggregatedSamples.isEmpty
1720
    }
1721

            
1722
    var body: some View {
1723
        VStack(alignment: .leading, spacing: 12) {
1724
            HStack(spacing: 8) {
1725
                Image(systemName: "chart.xyaxis.line")
1726
                    .foregroundColor(.blue)
1727
                Text("Session Chart")
1728
                    .font(.headline)
1729
                ContextInfoButton(
1730
                    title: "Session Chart",
1731
                    message: chartInfoMessage
1732
                )
1733
                Spacer(minLength: 0)
1734
            }
1735

            
1736
            MeasurementChartView(
1737
                timeRange: fixedTimeRange,
1738
                timeRangeLowerBound: liveTrimBounds.lower,
1739
                timeRangeUpperBound: liveTrimBounds.upper,
1740
                showsRangeSelector: showsRangeSelector,
1741
                rebasesEnergyToVisibleRangeStart: true,
1742
                extendsTimelineToPresent: false,
1743
                showsTemperatureSeries: false,
1744
                rangeSelectorConfiguration: rangeSelectorConfiguration
1745
            )
1746
            .environmentObject(chartMeasurements)
1747
            .frame(maxWidth: .infinity, alignment: .topLeading)
1748
        }
1749
        .padding(18)
1750
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1751
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1752
        .onChange(of: session.id) { _ in
1753
            restoreStoredMeasurementsIfNeeded()
1754
        }
1755
        .onChange(of: session.aggregatedSamples.count) { _ in
1756
            restoreStoredMeasurementsIfNeeded()
1757
        }
1758
    }
1759

            
1760
    private var chartInfoMessage: String {
1761
        if monitoringMeter != nil && session.status.isOpen {
1762
            return "This chart combines the persisted session curve with current live data from this meter."
1763
        }
1764

            
1765
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1766
    }
1767

            
1768
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1769
        switch controlMode {
1770
        case .none:
1771
            return nil
1772
        case .activeMonitoring:
1773
            return MeasurementChartRangeSelectorConfiguration(
1774
                keepAction: MeasurementChartSelectionAction(
1775
                    title: "Trim Start",
1776
                    shortTitle: "Start",
1777
                    systemName: "arrow.right.to.line",
1778
                    tone: .destructive,
1779
                    handler: applyActiveStartTrim
1780
                ),
1781
                removeAction: MeasurementChartSelectionAction(
1782
                    title: "Trim End & Finish",
1783
                    shortTitle: "End",
1784
                    systemName: "arrow.left.to.line",
1785
                    tone: .destructiveProminent,
1786
                    handler: requestActiveEndTrim
1787
                ),
1788
                resetAction: MeasurementChartResetAction(
1789
                    title: "Reset Trim",
1790
                    shortTitle: "Reset",
1791
                    systemName: "arrow.counterclockwise",
1792
                    tone: .reversible,
1793
                    confirmationTitle: "Reset session trim?",
1794
                    confirmationButtonTitle: "Reset trim",
1795
                    handler: {
1796
                        onSetTrim(nil, nil)
1797
                    }
1798
                )
1799
            )
1800
        case .closed:
1801
            return MeasurementChartRangeSelectorConfiguration(
1802
                keepAction: MeasurementChartSelectionAction(
1803
                    title: "Trim Window",
1804
                    shortTitle: "Trim",
1805
                    systemName: "scissors",
1806
                    tone: .destructive,
1807
                    handler: applyClosedTrim
1808
                ),
1809
                removeAction: nil,
1810
                resetAction: MeasurementChartResetAction(
1811
                    title: "Reset Trim",
1812
                    shortTitle: "Reset",
1813
                    systemName: "arrow.counterclockwise",
1814
                    tone: .reversible,
1815
                    confirmationTitle: "Reset session trim?",
1816
                    confirmationButtonTitle: "Reset trim",
1817
                    handler: {
1818
                        onSetTrim(nil, nil)
1819
                    }
1820
                )
1821
            )
1822
        }
1823
    }
1824

            
1825
    private func restoreStoredMeasurementsIfNeeded() {
1826
        guard monitoringMeter == nil || session.status.isOpen == false else {
1827
            return
1828
        }
1829
        storedMeasurements.resetSeries()
1830
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1831
            from: session,
1832
            replacingLiveBufferIfNeeded: true
1833
        )
1834
    }
1835

            
1836
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1837
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1838
    }
1839

            
1840
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1841
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1842
        let end = normalizedEnd(range.upperBound)
1843
        onStopWithTrim(start, end)
1844
    }
1845

            
1846
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1847
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1848
    }
1849

            
1850
    private func normalizedStart(_ date: Date) -> Date? {
1851
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1852
    }
1853

            
1854
    private func normalizedEnd(_ date: Date) -> Date? {
1855
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1856
    }
1857
}
1858

            
1859
private struct ChargeSessionStopRequest: Identifiable {
1860
    let sessionID: UUID
1861
    let title: String
1862
    let confirmTitle: String
1863
    let explanation: String
1864
    let appliesTrim: Bool
1865
    let trimStart: Date?
1866
    let trimEnd: Date?
1867

            
1868
    var id: String {
1869
        [
1870
            sessionID.uuidString,
1871
            title,
1872
            trimStart?.timeIntervalSince1970.description ?? "nil",
1873
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1874
        ].joined(separator: "-")
1875
    }
1876
}
1877

            
1878
private extension View {
1879
    func monitoringActionStyle(tint: Color) -> some View {
1880
        frame(maxWidth: .infinity)
1881
            .padding(.vertical, 10)
1882
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1883
            .buttonStyle(.plain)
1884
    }
1885

            
1886
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1887
        frame(maxWidth: .infinity)
1888
            .padding(.vertical, 9)
1889
            .meterCard(
1890
                tint: tint,
1891
                fillOpacity: isProminent ? 0.22 : 0.10,
1892
                strokeOpacity: isProminent ? 0.32 : 0.14,
1893
                cornerRadius: 14
1894
            )
1895
            .buttonStyle(.plain)
1896
    }
1897
}