Newer Older
2385 lines | 94.439kb
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

            
Bogdan Timofte authored a month ago
38
    private struct BatteryPercentCandidate {
39
        let timestamp: Date
40
        let percent: Double
41
        let isCheckpoint: Bool
42
    }
43

            
Bogdan Timofte authored a month ago
44
    @EnvironmentObject private var appData: AppData
45

            
46
    let chargedDeviceID: UUID
47
    let sessionID: UUID
48
    let monitoringMeter: Meter?
49
    let presentation: ChargeSessionDetailPresentation
50

            
51
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
52
    @State private var pendingSessionDeletion: ChargeSessionSummary?
53
    @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
Bogdan Timofte authored a month ago
54
    @State private var pendingTrimCommitSession: ChargeSessionSummary?
Bogdan Timofte authored a month ago
55
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
56
    @State private var trimBannerDismissedForSessionID: UUID?
57
    @State private var showingInlineTargetEditor = false
58
    @State private var draftTargetText = ""
59
    @State private var showingStopConfirm = false
60
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
61
    @State private var isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
62
    @State private var finalCheckpointText = ""
63
    @State private var stopFailureMessage: String?
64

            
65
    init(
66
        chargedDeviceID: UUID,
67
        sessionID: UUID,
68
        monitoringMeter: Meter? = nil,
69
        presentation: ChargeSessionDetailPresentation = .navigation
70
    ) {
71
        self.chargedDeviceID = chargedDeviceID
72
        self.sessionID = sessionID
73
        self.monitoringMeter = monitoringMeter
74
        self.presentation = presentation
75
    }
76

            
77
    private var chargedDevice: ChargedDeviceSummary? {
78
        appData.chargedDeviceSummary(id: chargedDeviceID)
79
    }
80

            
81
    private var session: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
82
        appData.chargeSessionSummary(id: sessionID)
83
            ?? chargedDevice?.sessions.first(where: { $0.id == sessionID })
84
    }
85

            
86
    private var chargedPowerbank: PowerbankSummary? {
87
        guard let powerbankID = session?.chargedPowerbankID else {
88
            return nil
89
        }
90
        return appData.powerbankSummaries.first { $0.id == powerbankID }
Bogdan Timofte authored a month ago
91
    }
92

            
93
    private var liveMonitoringMeter: Meter? {
94
        guard let session,
95
              session.status.isOpen,
96
              let meterMACAddress = session.meterMACAddress else {
97
            return nil
98
        }
99

            
100
        if let monitoringMeter,
101
           monitoringMeter.btSerial.macAddress.description == meterMACAddress {
102
            return monitoringMeter
103
        }
104

            
105
        return appData.meters.values.first {
106
            $0.btSerial.macAddress.description == meterMACAddress
107
        }
108
    }
109

            
110
    private var hasMonitoringControls: Bool {
111
        session?.status.isOpen == true && liveMonitoringMeter != nil
112
    }
113

            
114
    private var shouldShowTrimBanner: Bool {
115
        guard hasMonitoringControls,
116
              let session,
117
              session.isTrimmed == false,
118
              trimBannerDismissedForSessionID != session.id,
119
              let detectedTrimWindow else {
120
            return false
121
        }
122
        return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold
123
    }
124

            
125
    var body: some View {
126
        Group {
127
            if let chargedDevice, let session {
128
                content(chargedDevice: chargedDevice, session: session)
Bogdan Timofte authored a month ago
129
            } else if let chargedPowerbank, let session {
130
                powerbankContent(powerbank: chargedPowerbank, session: session)
Bogdan Timofte authored a month ago
131
            } else {
132
                unavailableState
133
            }
134
        }
135
        .sheet(item: $pendingSessionStopRequest) { request in
136
            ChargeSessionCompletionSheetView(
137
                sessionID: request.sessionID,
138
                title: request.title,
139
                confirmTitle: request.confirmTitle,
140
                explanation: request.explanation,
141
                monitoringMeter: liveMonitoringMeter,
142
                appliesTrim: request.appliesTrim,
143
                trimStart: request.trimStart,
144
                trimEnd: request.trimEnd
145
            )
146
            .environmentObject(appData)
147
        }
148
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
149
            Alert(
150
                title: Text("Delete Battery Checkpoint"),
151
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
152
                primaryButton: .destructive(Text("Delete")) {
153
                    _ = appData.deleteBatteryCheckpoint(
154
                        checkpointID: checkpoint.id,
155
                        for: checkpoint.sessionID
156
                    )
157
                },
158
                secondaryButton: .cancel()
159
            )
160
        }
161
        .alert(item: $pendingSessionDeletion) { session in
162
            Alert(
163
                title: Text("Delete Session?"),
164
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
165
                primaryButton: .destructive(Text("Delete")) {
166
                    _ = appData.deleteChargeSession(sessionID: session.id)
167
                },
168
                secondaryButton: .cancel()
169
            )
170
        }
Bogdan Timofte authored a month ago
171
        .alert(item: $pendingTrimCommitSession) { session in
172
            Alert(
173
                title: Text("Save Trim Permanently?"),
174
                message: Text("Samples and checkpoints outside \(session.effectiveTrimStart.format()) - \(session.effectiveTrimEnd.format()) will be deleted. Reset Trim will no longer restore them."),
175
                primaryButton: .destructive(Text("Save Trim")) {
176
                    _ = appData.commitSessionTrim(sessionID: session.id)
177
                },
178
                secondaryButton: .cancel()
179
            )
180
        }
Bogdan Timofte authored a month ago
181
        .onAppear {
182
            syncMonitoringRestore()
183
            runTrimDetection()
184
        }
185
        .onChange(of: session?.id) { _ in
186
            pendingSessionStopRequest = nil
Bogdan Timofte authored a month ago
187
            pendingTrimCommitSession = nil
Bogdan Timofte authored a month ago
188
            detectedTrimWindow = nil
189
            trimBannerDismissedForSessionID = nil
190
            showingInlineTargetEditor = false
191
            draftTargetText = ""
192
            showingStopConfirm = false
193
            finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
194
            isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
195
            finalCheckpointText = ""
196
            stopFailureMessage = nil
197
            syncMonitoringRestore()
198
            runTrimDetection()
199
        }
200
        .onChange(of: session?.aggregatedSamples.count) { _ in
201
            syncMonitoringRestore()
202
            runTrimDetection()
203
        }
Bogdan Timofte authored a month ago
204
        .onChange(of: session?.checkpoints.count) { _ in
205
            syncMonitoringRestore()
206
        }
Bogdan Timofte authored a month ago
207
        .onChange(of: finalCheckpointMode) { _ in
208
            stopFailureMessage = nil
209
        }
210
        .onChange(of: finalCheckpointText) { _ in
211
            stopFailureMessage = nil
212
        }
213
    }
214

            
215
    private func content(
216
        chargedDevice: ChargedDeviceSummary,
217
        session: ChargeSessionSummary
218
    ) -> some View {
219
        ScrollView {
220
            VStack(spacing: 16) {
221
                if hasMonitoringControls {
222
                    monitoringSessionCard(session, chargedDevice: chargedDevice)
223

            
224
                    if shouldShowTrimBanner {
225
                        trimDetectionBanner(session)
226
                    }
227

            
228
                    if shouldShowSessionChart(session) {
Bogdan Timofte authored a month ago
229
                        chartCard(session, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
230
                    }
231
                } else {
232
                    overviewCard(session, chargedDevice: chargedDevice)
233
                    batteryCard(session, chargedDevice: chargedDevice)
234

            
235
                    if shouldShowSessionChart(session) {
Bogdan Timofte authored a month ago
236
                        chartCard(session, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
237
                    }
238

            
239
                    if session.status.isOpen {
240
                        followerNoticeCard(session)
241
                    }
242
                }
243
            }
244
            .padding(presentation == .embedded ? 16 : 20)
245
        }
246
        .background(
247
            LinearGradient(
248
                colors: [statusTint(for: session).opacity(0.14), Color.clear],
249
                startPoint: .topLeading,
250
                endPoint: .bottomTrailing
251
            )
252
            .ignoresSafeArea()
253
        )
254
        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
Bogdan Timofte authored a month ago
255
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
256
        .toolbar {
257
            ToolbarItemGroup(placement: .primaryAction) {
258
                if session.status.isOpen == false {
259
                    Button(role: .destructive) {
260
                        pendingSessionDeletion = session
261
                    } label: {
262
                        Image(systemName: "trash")
263
                    }
264
                    .help("Delete session")
265
                }
266
            }
267
        }
Bogdan Timofte authored a month ago
268
    }
269

            
270
    private var unavailableState: some View {
271
        VStack(spacing: 12) {
272
            Image(systemName: "bolt.slash")
273
                .font(.title2)
274
                .foregroundColor(.secondary)
275
            Text("This session is no longer available.")
276
                .font(.headline)
277
            Text("It may have been deleted or synced from another device.")
278
                .font(.footnote)
279
                .foregroundColor(.secondary)
280
                .multilineTextAlignment(.center)
281
        }
282
        .frame(maxWidth: .infinity, maxHeight: .infinity)
283
        .padding(24)
284
        .navigationTitle("Session")
Bogdan Timofte authored a month ago
285
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
286
    }
287

            
Bogdan Timofte authored a month ago
288
    private func powerbankContent(
289
        powerbank: PowerbankSummary,
290
        session: ChargeSessionSummary
291
    ) -> some View {
292
        ScrollView {
293
            VStack(spacing: 16) {
294
                powerbankSessionCard(powerbank: powerbank, session: session)
295

            
296
                if shouldShowSessionChart(session) {
297
                    powerbankChartCard(session)
298
                }
299

            
300
                if session.status.isOpen && !hasMonitoringControls {
301
                    followerNoticeCard(session)
302
                }
303
            }
304
            .padding(presentation == .embedded ? 16 : 20)
305
        }
306
        .background(
307
            LinearGradient(
308
                colors: [statusTint(for: session).opacity(0.14), Color.clear],
309
                startPoint: .topLeading,
310
                endPoint: .bottomTrailing
311
            )
312
            .ignoresSafeArea()
313
        )
314
        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
315
        .navigationBarTitleDisplayMode(.inline)
316
        .toolbar {
317
            ToolbarItemGroup(placement: .primaryAction) {
318
                if session.status.isOpen == false {
319
                    Button(role: .destructive) {
320
                        pendingSessionDeletion = session
321
                    } label: {
322
                        Image(systemName: "trash")
323
                    }
324
                    .help("Delete session")
325
                }
326
            }
327
        }
328
    }
329

            
330
    private func powerbankSessionCard(
331
        powerbank: PowerbankSummary,
332
        session: ChargeSessionSummary
333
    ) -> some View {
334
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
335

            
336
        return VStack(alignment: .leading, spacing: 14) {
337
            HStack {
338
                Label(powerbank.name, systemImage: powerbank.identitySymbolName)
339
                    .font(.headline)
340

            
341
                Spacer()
342

            
343
                Text(session.status.title)
344
                    .font(.caption.weight(.bold))
345
                    .foregroundColor(monitoringStatusColor(for: session))
346
                    .padding(.horizontal, 8)
347
                    .padding(.vertical, 4)
348
                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
349
            }
350

            
351
            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
352
                metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
353
                metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
354
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
355
                metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
356
            }
357

            
358
            if let sourcePowerbankID = session.sourcePowerbankID,
359
               let sourcePowerbank = appData.powerbankSummaries.first(where: { $0.id == sourcePowerbankID }) {
360
                MeterInfoRowView(label: "Source Powerbank", value: sourcePowerbank.name)
361
            }
362

            
363
            BatteryCheckpointSectionView(
364
                sessionID: session.id,
365
                checkpoints: session.checkpoints,
366
                message: "Checkpoints are stored on the powerbank charge session and help estimate received capacity.",
367
                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
368
                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
369
                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
370
                effectiveEnergyWhOverride: displayedEnergyWh,
371
                onDelete: { checkpoint in
372
                    pendingCheckpointDeletion = checkpoint
373
                }
374
            )
375

            
376
            if showingStopConfirm {
377
                stopConfirmPanel(session: session, displayedEnergyWh: displayedEnergyWh)
378
            } else if hasMonitoringControls {
379
                monitoringActionRow(session)
380
            }
381
        }
382
        .padding(18)
383
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
384
    }
385

            
Bogdan Timofte authored a month ago
386
    private func monitoringSessionCard(
387
        _ session: ChargeSessionSummary,
388
        chargedDevice: ChargedDeviceSummary
389
    ) -> some View {
390
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
391
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
392
            for: session,
393
            effectiveEnergyWhOverride: displayedEnergyWh
394
        )
395

            
396
        return VStack(alignment: .leading, spacing: 14) {
397
            HStack {
398
                ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
399
                    .font(.headline)
400

            
401
                Spacer()
402

            
403
                Text(session.status.title)
404
                    .font(.caption.weight(.bold))
405
                    .foregroundColor(monitoringStatusColor(for: session))
406
                    .padding(.horizontal, 8)
407
                    .padding(.vertical, 4)
408
                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
409
            }
410

            
411
            if let batteryPrediction {
412
                batteryGaugeSection(
413
                    prediction: batteryPrediction,
414
                    session: session,
415
                    displayedEnergyWh: displayedEnergyWh
416
                )
417
            }
418

            
419
            sessionMetricsGrid(
420
                session: session,
421
                chargedDevice: chargedDevice,
422
                displayedEnergyWh: displayedEnergyWh,
423
                hasPrediction: batteryPrediction != nil
424
            )
425

            
426
            if session.stopThresholdAmps > 0 {
427
                Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
428
                    .font(.caption)
429
                    .foregroundColor(.secondary)
430
            }
431

            
432
            if let sessionWarning = sessionWarning(for: session) {
433
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
434
                    .font(.caption)
435
                    .foregroundColor(.orange)
436
            }
437

            
438
            if session.isPaused {
439
                Label(
440
                    "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
441
                    systemImage: "pause.circle"
442
                )
443
                .font(.caption)
444
                .foregroundColor(.secondary)
445
            }
446

            
447
            if session.requiresCompletionConfirmation && !showingStopConfirm {
448
                completionConfirmationCard(session)
449
            }
450

            
451
            BatteryCheckpointSectionView(
452
                sessionID: session.id,
453
                checkpoints: session.checkpoints,
454
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
455
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
456
                canDeleteCheckpoint: true,
457
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
458
                effectiveEnergyWhOverride: displayedEnergyWh,
459
                onDelete: { checkpoint in
460
                    pendingCheckpointDeletion = checkpoint
461
                }
462
            )
463

            
464
            targetSectionView(
465
                session: session,
466
                predictedPercent: batteryPrediction?.predictedPercent
467
            )
468

            
469
            if showingStopConfirm {
470
                stopConfirmPanel(
471
                    session: session,
Bogdan Timofte authored a month ago
472
                    displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
473
                )
474
            } else {
475
                monitoringActionRow(session)
476
            }
477
        }
478
        .padding(18)
479
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
480
    }
481

            
482
    private func overviewCard(
483
        _ session: ChargeSessionSummary,
484
        chargedDevice: ChargedDeviceSummary
485
    ) -> some View {
Bogdan Timofte authored a month ago
486
        MeterInfoCardView(
487
            title: session.status.isOpen ? "Open Session" : "Overview",
488
            tint: statusTint(for: session),
Bogdan Timofte authored a month ago
489
            isCollapsible: true,
490
            initiallyExpanded: false,
491
            trailingActions: {
492
                HStack(spacing: 4) {
493
                    Text(session.startedAt, style: .time)
494
                        .font(.caption2)
495
                        .foregroundColor(.secondary)
496
                        .monospacedDigit()
497
                    Text("·")
498
                        .font(.caption2)
499
                        .foregroundColor(.secondary)
500
                    Text(sessionDurationText(session))
501
                        .font(.caption2.weight(.semibold))
502
                        .foregroundColor(.secondary)
503
                        .monospacedDigit()
504
                }
505
            }
Bogdan Timofte authored a month ago
506
        ) {
507
            VStack(alignment: .leading, spacing: 10) {
508
                MeterInfoRowView(label: "Device", value: chargedDevice.name)
509

            
510
                Divider()
511

            
512
                HStack(alignment: .top, spacing: 12) {
513
                    overviewStatCell(label: "Started", value: session.startedAt.format())
514
                    if let endedAt = session.endedAt {
515
                        overviewStatCell(label: "Ended", value: endedAt.format())
516
                    }
517
                }
518

            
519
                HStack(alignment: .top, spacing: 12) {
520
                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
521
                    overviewStatCell(label: "Status", value: session.status.title)
522
                }
523

            
524
                Divider()
525

            
526
                HStack(alignment: .top, spacing: 12) {
527
                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
528
                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
529
                }
530

            
531
                HStack(alignment: .top, spacing: 12) {
532
                    overviewStatCell(label: "Source", value: session.sourceMode.title)
533
                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
534
                }
535

            
536
                if session.isTrimmed {
537
                    Divider()
538
                    HStack(alignment: .top, spacing: 12) {
539
                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
540
                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
541
                    }
542
                }
543

            
544
                let meterLabel: String? = session.meterName ?? session.meterMACAddress
545
                if meterLabel != nil || session.meterModel != nil {
546
                    Divider()
547
                    HStack(alignment: .top, spacing: 12) {
548
                        if let label = meterLabel {
549
                            overviewStatCell(label: "Meter", value: label)
550
                        }
551
                        if let model = session.meterModel {
552
                            overviewStatCell(label: "Meter Model", value: model)
553
                        }
554
                    }
555
                }
Bogdan Timofte authored a month ago
556

            
557
                if session.minimumObservedCurrentAmps != nil
558
                    || session.maximumObservedCurrentAmps != nil
559
                    || session.maximumObservedPowerWatts != nil
560
                    || session.maximumObservedVoltageVolts != nil
561
                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
562
                    || session.completionCurrentAmps != nil
563
                    || session.selectedDataGroup != nil {
564

            
565
                    Divider()
566

            
567
                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
568
                        HStack(alignment: .top, spacing: 12) {
569
                            if let v = session.minimumObservedCurrentAmps {
570
                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
571
                            }
572
                            if let v = session.maximumObservedCurrentAmps {
573
                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
574
                            }
575
                        }
576
                    }
577

            
578
                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
579
                        HStack(alignment: .top, spacing: 12) {
580
                            if let v = session.maximumObservedPowerWatts {
581
                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
582
                            }
583
                            if let v = session.maximumObservedVoltageVolts {
584
                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
585
                            }
586
                        }
587
                    }
588

            
589
                    if session.completionCurrentAmps != nil
590
                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
591
                        HStack(alignment: .top, spacing: 12) {
592
                            if let v = session.completionCurrentAmps {
593
                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
594
                            }
595
                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
596
                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
597
                            }
598
                        }
599
                    }
600

            
601
                    if let dg = session.selectedDataGroup {
602
                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
603
                    }
604
                }
Bogdan Timofte authored a month ago
605
            }
606
        }
607
    }
608

            
Bogdan Timofte authored a month ago
609
    private func overviewStatCell(label: String, value: String) -> some View {
610
        VStack(alignment: .leading, spacing: 2) {
611
            Text(label)
612
                .font(.caption2)
613
                .foregroundColor(.secondary)
614
            Text(value)
615
                .font(.footnote.weight(.medium))
616
                .monospacedDigit()
617
        }
618
        .frame(maxWidth: .infinity, alignment: .leading)
619
    }
620

            
Bogdan Timofte authored a month ago
621
    private func batteryCard(
622
        _ session: ChargeSessionSummary,
623
        chargedDevice: ChargedDeviceSummary
624
    ) -> some View {
625
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
626
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
627
            for: session,
628
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
629
        )
630

            
Bogdan Timofte authored a month ago
631
        let startPercent = session.startBatteryPercent
632
        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
633
        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
634
        let showsPreview = startPercent != nil && endPercent != nil
Bogdan Timofte authored a month ago
635

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

            
638
            // Header — always visible, tappable
639
            HStack(spacing: 8) {
640
                Text("Battery")
641
                    .font(.headline)
642
                Spacer(minLength: 0)
643
                Image(systemName: "chevron.up")
644
                    .font(.caption.weight(.semibold))
645
                    .foregroundColor(.secondary)
646
                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
647
                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
648
            }
649
            .contentShape(Rectangle())
650
            .onTapGesture {
651
                withAnimation(.easeInOut(duration: 0.25)) {
652
                    isBatteryCardExpanded.toggle()
Bogdan Timofte authored a month ago
653
                }
Bogdan Timofte authored a month ago
654
            }
Bogdan Timofte authored a month ago
655

            
Bogdan Timofte authored a month ago
656
            // Preview bar — always visible when there is enough data
657
            if showsPreview, let start = startPercent, let end = endPercent {
658
                batteryPreviewBar(
659
                    startPercent: start,
660
                    endPercent: end,
661
                    checkpoints: session.checkpoints,
662
                    isEstimatedEnd: isEstimatedEnd
663
                )
664
                .padding(.top, 10)
665
            }
666

            
667
            // Collapsible detail
668
            if isBatteryCardExpanded {
669
                VStack(alignment: .leading, spacing: 10) {
670

            
671
                    // Energy
Bogdan Timofte authored a month ago
672
                    HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
673
                        overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
674
                        if let capacityEstimateWh = session.capacityEstimateWh {
675
                            overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
676
                        }
677
                    }
Bogdan Timofte authored a month ago
678
                    if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
679
                        MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
680
                    }
681
                    if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
682
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
683
                        MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
684
                    }
685
                    if let chargerID = session.chargerID,
686
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
687
                        MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
688
                    }
689
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
690
                        Text(wirelessSessionHint)
691
                            .font(.caption2)
692
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
693
                    }
694
                    if let sessionWarning = sessionWarning(for: session) {
695
                        Label(sessionWarning, systemImage: "exclamationmark.triangle")
696
                            .font(.caption2)
697
                            .foregroundColor(.orange)
698
                    }
699

            
700
                    // Battery percentages
701
                    if startPercent != nil || session.endBatteryPercent != nil {
702
                        Divider()
Bogdan Timofte authored a month ago
703
                        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
704
                            if let v = startPercent {
Bogdan Timofte authored a month ago
705
                                overviewStatCell(
706
                                    label: "Start Battery",
707
                                    value: session.startsFromFlatBattery ? "Flat" : "\(v.format(decimalDigits: 0))%"
708
                                )
Bogdan Timofte authored a month ago
709
                            }
Bogdan Timofte authored a month ago
710
                            if let v = session.endBatteryPercent {
711
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
712
                            }
713
                        }
Bogdan Timofte authored a month ago
714
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
715
                            HStack(alignment: .top, spacing: 12) {
716
                                if let v = session.batteryDeltaPercent {
717
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
718
                                }
719
                                if let v = session.targetBatteryPercent {
720
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
721
                                }
722
                            }
723
                        }
724
                        if let batteryPrediction {
725
                            HStack(alignment: .top, spacing: 12) {
726
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
727
                            }
Bogdan Timofte authored a month ago
728
                            Text(batteryPredictionExplanation(batteryPrediction))
Bogdan Timofte authored a month ago
729
                            .font(.caption2)
730
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
731
                        }
732
                    }
Bogdan Timofte authored a month ago
733

            
734
                    // Checkpoints
735
                    Divider()
736
                    BatteryCheckpointSectionView(
737
                        sessionID: session.id,
738
                        checkpoints: session.checkpoints,
739
                        message: session.status.isOpen
740
                            ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
741
                            : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
742
                        canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
743
                        canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
744
                        requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
745
                        effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
746
                        onDelete: { checkpoint in
747
                            pendingCheckpointDeletion = checkpoint
748
                        }
749
                    )
Bogdan Timofte authored a month ago
750
                }
Bogdan Timofte authored a month ago
751
                .padding(.top, 12)
752
                .transition(.opacity.combined(with: .move(edge: .top)))
753
            }
754
        }
755
        .frame(maxWidth: .infinity, alignment: .leading)
756
        .padding(18)
757
        .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
758
    }
Bogdan Timofte authored a month ago
759

            
Bogdan Timofte authored a month ago
760
    private func batteryPreviewBar(
761
        startPercent: Double,
762
        endPercent: Double,
763
        checkpoints: [ChargeCheckpointSummary],
764
        isEstimatedEnd: Bool
765
    ) -> some View {
766
        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
767
        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
768
        let color = batteryColor(for: endPercent)
769

            
770
        return HStack(spacing: 6) {
771
            Text("\(Int(startPercent.rounded()))%")
772
                .font(.caption2.weight(.semibold))
773
                .foregroundColor(.secondary)
774
                .monospacedDigit()
775
                .frame(minWidth: 26, alignment: .trailing)
776

            
777
            GeometryReader { geo in
778
                let w = geo.size.width
779
                ZStack(alignment: .leading) {
780
                    Capsule()
781
                        .fill(Color.primary.opacity(0.10))
782

            
783
                    Rectangle()
784
                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
785
                        .frame(width: max(w * (endFrac - startFrac), 3))
786
                        .offset(x: w * startFrac)
787

            
788
                    ForEach(checkpoints, id: \.id) { cp in
789
                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
790
                        let isFinal = cp.flag == .final
791
                        Rectangle()
792
                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
793
                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
794
                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
Bogdan Timofte authored a month ago
795
                    }
Bogdan Timofte authored a month ago
796
                }
797
                .clipShape(Capsule())
798
            }
799
            .frame(height: 8)
800

            
801
            HStack(spacing: 1) {
802
                if isEstimatedEnd {
803
                    Text("~")
804
                        .font(.caption2)
805
                        .foregroundColor(.secondary)
806
                }
807
                Text("\(Int(endPercent.rounded()))%")
808
                    .font(.caption2.weight(.semibold))
809
                    .foregroundColor(isEstimatedEnd ? .secondary : color)
810
                    .monospacedDigit()
Bogdan Timofte authored a month ago
811
            }
Bogdan Timofte authored a month ago
812
            .frame(minWidth: 32, alignment: .leading)
Bogdan Timofte authored a month ago
813
        }
814
    }
815

            
816
    private func batteryGaugeSection(
817
        prediction: BatteryLevelPrediction,
818
        session: ChargeSessionSummary,
819
        displayedEnergyWh: Double
820
    ) -> some View {
821
        let percent = prediction.predictedPercent
822
        let color = batteryColor(for: percent)
823
        let duration = displayedSessionDuration(for: session)
824
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
825
            ? displayedEnergyWh / duration
826
            : nil
827
        let etaToFull = etaText(
828
            rateWhPerSec: rateWhPerSec,
Bogdan Timofte authored a month ago
829
            remainingWh: max((prediction.energyWh(forPercent: 100) ?? displayedEnergyWh) - displayedEnergyWh, 0),
830
            isRelevant: percent < 98 && prediction.estimatedCapacityWh != nil
Bogdan Timofte authored a month ago
831
        )
832
        let etaToTarget = etaToTargetText(
833
            session: session,
834
            prediction: prediction,
835
            displayedEnergyWh: displayedEnergyWh,
836
            rateWhPerSec: rateWhPerSec
837
        )
838

            
839
        return VStack(spacing: 10) {
840
            HStack(alignment: .lastTextBaseline, spacing: 8) {
841
                HStack(alignment: .lastTextBaseline, spacing: 3) {
842
                    Text("\(Int(percent.rounded()))")
843
                        .font(.system(size: 52, weight: .bold, design: .rounded))
844
                        .foregroundColor(color)
845
                        .monospacedDigit()
846
                    Text("%")
847
                        .font(.title2.weight(.semibold))
848
                        .foregroundColor(color.opacity(0.8))
849
                }
850

            
851
                Spacer()
852

            
Bogdan Timofte authored a month ago
853
                if let estimatedCapacityWh = prediction.estimatedCapacityWh {
854
                    VStack(alignment: .trailing, spacing: 2) {
855
                        Text("\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
856
                            .font(.callout.weight(.bold))
857
                            .foregroundColor(.orange)
858
                            .monospacedDigit()
859
                        Text(prediction.basis.metricLabel)
860
                            .font(.caption2)
861
                            .foregroundColor(.secondary)
862
                    }
Bogdan Timofte authored a month ago
863
                }
864
            }
865

            
866
            batteryProgressBar(
867
                percent: percent,
868
                startPercent: session.startBatteryPercent,
869
                targetPercent: session.targetBatteryPercent
870
            )
871

            
872
            HStack(spacing: 14) {
873
                if let etaToFull {
874
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
875
                }
876

            
877
                if let etaToTarget, let target = session.targetBatteryPercent {
878
                    etaPill(
879
                        icon: "bell.badge.fill",
880
                        tint: .indigo,
881
                        value: etaToTarget,
882
                        label: "to \(Int(target.rounded()))%"
883
                    )
884
                }
885

            
886
                Spacer()
887

            
888
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
889
                    .font(.caption2)
890
                    .foregroundColor(.secondary)
891
                    .multilineTextAlignment(.trailing)
892
            }
893
        }
894
        .padding(14)
895
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
896
    }
897

            
898
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
899
        VStack(alignment: .leading, spacing: 1) {
900
            HStack(spacing: 4) {
901
                Image(systemName: icon)
902
                    .font(.caption)
903
                    .foregroundColor(tint)
904
                Text(value)
905
                    .font(.caption.weight(.bold))
906
            }
907
            Text(label)
908
                .font(.caption2)
909
                .foregroundColor(.secondary)
910
        }
911
    }
912

            
913
    private func batteryProgressBar(
914
        percent: Double,
915
        startPercent: Double?,
916
        targetPercent: Double?
917
    ) -> some View {
918
        let color = batteryColor(for: percent)
919
        return GeometryReader { geo in
920
            let width = geo.size.width
921
            ZStack(alignment: .leading) {
922
                Capsule()
923
                    .fill(Color.primary.opacity(0.10))
924
                Rectangle()
925
                    .fill(
926
                        LinearGradient(
927
                            colors: [color.opacity(0.6), color],
928
                            startPoint: .leading,
929
                            endPoint: .trailing
930
                        )
931
                    )
932
                    .frame(width: max(width * CGFloat(percent / 100), 4))
933
                    .animation(.easeInOut(duration: 0.4), value: percent)
934
                if let start = startPercent, start > 2, start < 98 {
935
                    Rectangle()
936
                        .fill(Color.white.opacity(0.55))
937
                        .frame(width: 2, height: 20)
938
                        .offset(x: width * CGFloat(start / 100) - 1)
939
                }
940
                if let target = targetPercent {
941
                    Rectangle()
942
                        .fill(Color.indigo.opacity(0.9))
943
                        .frame(width: 2.5, height: 20)
944
                        .offset(x: width * CGFloat(target / 100) - 1.25)
945
                }
946
            }
947
            .clipShape(Capsule())
948
        }
949
        .frame(height: 20)
950
    }
951

            
952
    private func sessionMetricsGrid(
953
        session: ChargeSessionSummary,
954
        chargedDevice: ChargedDeviceSummary,
955
        displayedEnergyWh: Double,
956
        hasPrediction: Bool
957
    ) -> some View {
958
        let capacityFallback: Double? = hasPrediction ? nil : (
959
            session.capacityEstimateWh
960
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
961
                ?? chargedDevice.estimatedBatteryCapacityWh
962
        )
963
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
964

            
965
        return LazyVGrid(columns: columns, spacing: 8) {
966
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
967
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
968

            
969
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
970
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
971
            }
972
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
973
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
974
            }
975

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

            
978
            if let capacityFallback {
979
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
980
            }
981
        }
982
    }
983

            
984
    private func metricCell(label: String, value: String, tint: Color) -> some View {
985
        VStack(alignment: .leading, spacing: 3) {
986
            Text(label)
987
                .font(.caption2)
988
                .foregroundColor(.secondary)
989
            Text(value)
990
                .font(.subheadline.weight(.semibold))
991
                .lineLimit(1)
992
                .minimumScaleFactor(0.7)
993
                .monospacedDigit()
994
        }
995
        .frame(maxWidth: .infinity, alignment: .leading)
996
        .padding(.horizontal, 12)
997
        .padding(.vertical, 10)
998
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
999
    }
1000

            
1001
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
1002
        VStack(alignment: .leading, spacing: 10) {
1003
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
1004
                .font(.subheadline.weight(.semibold))
1005

            
1006
            if let contradictionPercent = session.completionContradictionPercent {
1007
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
1008
                    .font(.caption)
1009
                    .foregroundColor(.secondary)
1010
            } else {
1011
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
1012
                    .font(.caption)
1013
                    .foregroundColor(.secondary)
1014
            }
1015

            
1016
            HStack(spacing: 10) {
1017
                Button("Finish") {
1018
                    beginStopConfirmation(for: session)
1019
                }
1020
                .frame(maxWidth: .infinity)
1021
                .padding(.vertical, 9)
1022
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1023
                .buttonStyle(.plain)
1024

            
1025
                Button("Keep Monitoring") {
1026
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
1027
                }
1028
                .frame(maxWidth: .infinity)
1029
                .padding(.vertical, 9)
1030
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1031
                .buttonStyle(.plain)
1032
            }
1033
        }
1034
        .padding(14)
1035
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
1036
    }
1037

            
1038
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
1039
        let draftBelowPrediction: Bool = {
1040
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
1041
            return draft <= predictedPercent
1042
        }()
1043
        let savedBelowPrediction: Bool = {
1044
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
1045
            return saved <= predictedPercent
1046
        }()
1047

            
1048
        return HStack(alignment: .center, spacing: 8) {
1049
            Image(systemName: "bell.badge")
1050
                .foregroundColor(.indigo)
1051
                .font(.subheadline)
1052

            
1053
            Text("Notify at")
1054
                .font(.subheadline.weight(.semibold))
1055

            
1056
            Spacer(minLength: 8)
1057

            
1058
            if showingInlineTargetEditor {
1059
                targetEditorControls(
1060
                    session: session,
1061
                    draftBelowPrediction: draftBelowPrediction,
1062
                    predictedPercent: predictedPercent
1063
                )
1064
            } else {
1065
                savedTargetControls(
1066
                    session: session,
1067
                    savedBelowPrediction: savedBelowPrediction,
1068
                    predictedPercent: predictedPercent
1069
                )
1070
            }
1071
        }
1072
    }
1073

            
1074
    private func targetEditorControls(
1075
        session: ChargeSessionSummary,
1076
        draftBelowPrediction: Bool,
1077
        predictedPercent: Double?
1078
    ) -> some View {
1079
        Group {
1080
            Button {
1081
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1082
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
1083
            } label: {
1084
                Image(systemName: "minus.circle")
1085
                    .font(.title3)
1086
            }
1087
            .buttonStyle(.plain)
1088

            
1089
            TextField("-", text: $draftTargetText)
1090
                .keyboardType(.decimalPad)
1091
                .textFieldStyle(.roundedBorder)
1092
                .frame(width: 48)
1093
                .multilineTextAlignment(.center)
1094
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
1095

            
1096
            Text("%")
1097
                .font(.subheadline)
1098
                .foregroundColor(.secondary)
1099

            
1100
            if draftBelowPrediction, let predictedPercent {
1101
                predictionWarningButton(predictedPercent: predictedPercent)
1102
            }
1103

            
1104
            Button {
1105
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1106
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
1107
            } label: {
1108
                Image(systemName: "plus.circle")
1109
                    .font(.title3)
1110
            }
1111
            .buttonStyle(.plain)
1112

            
1113
            Button {
1114
                if let value = parsedDraftTarget {
1115
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
1116
                }
1117
                showingInlineTargetEditor = false
1118
            } label: {
1119
                Image(systemName: "checkmark.circle.fill")
1120
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1121
                    .font(.title3)
1122
            }
1123
            .buttonStyle(.plain)
1124
            .disabled(parsedDraftTarget == nil)
1125

            
1126
            Button {
1127
                showingInlineTargetEditor = false
1128
                draftTargetText = ""
1129
            } label: {
1130
                Image(systemName: "xmark.circle")
1131
                    .foregroundColor(.secondary)
1132
                    .font(.title3)
1133
            }
1134
            .buttonStyle(.plain)
1135
        }
1136
    }
1137

            
1138
    private func savedTargetControls(
1139
        session: ChargeSessionSummary,
1140
        savedBelowPrediction: Bool,
1141
        predictedPercent: Double?
1142
    ) -> some View {
1143
        Group {
1144
            if let targetPercent = session.targetBatteryPercent {
1145
                Text("\(targetPercent.format(decimalDigits: 0))%")
1146
                    .font(.subheadline.weight(.semibold))
1147
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1148

            
1149
                if savedBelowPrediction, let predictedPercent {
1150
                    predictionWarningButton(predictedPercent: predictedPercent)
1151
                }
1152

            
1153
                Button {
1154
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
1155
                } label: {
1156
                    Image(systemName: "xmark.circle.fill")
1157
                        .foregroundColor(.secondary)
1158
                        .font(.callout)
1159
                }
1160
                .buttonStyle(.plain)
1161
                .help("Remove alert")
1162
            }
1163

            
1164
            Button {
1165
                draftTargetText = session.targetBatteryPercent.map {
1166
                    $0.format(decimalDigits: 0)
1167
                } ?? "80"
1168
                showingInlineTargetEditor = true
1169
            } label: {
1170
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1171
                    .font(.caption.weight(.semibold))
1172
                    .frame(width: 30, height: 30)
1173
                    .contentShape(Rectangle())
1174
            }
1175
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1176
            .buttonStyle(.plain)
1177
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1178
        }
1179
    }
1180

            
1181
    private func predictionWarningButton(predictedPercent: Double) -> some View {
1182
        Button {} label: {
1183
            Image(systemName: "exclamationmark.triangle.fill")
1184
                .font(.callout.weight(.semibold))
1185
                .foregroundColor(.orange)
1186
        }
1187
        .buttonStyle(.plain)
1188
        .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.")
1189
    }
1190

            
1191
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
1192
        HStack(spacing: 10) {
1193
            if session.status == .active {
1194
                Button("Pause") {
1195
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1196
                }
1197
                .monitoringActionStyle(tint: .orange)
1198
            } else if session.status == .paused {
1199
                Button("Resume") {
1200
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1201
                }
1202
                .monitoringActionStyle(tint: .blue)
1203
            }
1204

            
1205
            Button("Terminate Session") {
1206
                beginStopConfirmation(for: session)
1207
            }
1208
            .monitoringActionStyle(tint: .red)
1209
        }
1210
    }
1211

            
1212
    private func stopConfirmPanel(
1213
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1214
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1215
    ) -> some View {
1216
        let canSave = hasSavableChargeData(
1217
            session: session,
Bogdan Timofte authored a month ago
1218
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1219
        )
1220
        let saveDisabledReason = saveDisabledReason(
1221
            session: session,
Bogdan Timofte authored a month ago
1222
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1223
        )
1224
        let isSaveEnabled = saveDisabledReason == nil
1225

            
1226
        return VStack(alignment: .leading, spacing: 12) {
1227
            HStack {
1228
                Text("Final Checkpoint")
1229
                    .font(.subheadline.weight(.semibold))
1230
                Text("optional")
1231
                    .font(.caption2.weight(.semibold))
1232
                    .foregroundColor(.secondary)
1233
            }
1234

            
1235
            finalCheckpointPicker(session)
1236

            
1237
            if finalCheckpointMode == .custom {
1238
                customFinalCheckpointRow
1239
            }
1240

            
1241
            if let saveDisabledReason {
1242
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
1243
                    .font(.caption)
1244
                    .foregroundColor(.red)
1245
                    .fixedSize(horizontal: false, vertical: true)
1246
            } else if let stopFailureMessage {
1247
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
1248
                    .font(.caption)
1249
                    .foregroundColor(.red)
1250
                    .fixedSize(horizontal: false, vertical: true)
1251
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
1252
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
1253
                    .font(.caption)
1254
                    .foregroundColor(.green)
1255
                    .fixedSize(horizontal: false, vertical: true)
1256
            }
1257

            
1258
            HStack(spacing: 8) {
1259
                Button("Discard") {
1260
                    discardSession(session)
1261
                }
1262
                .monitoringPanelActionStyle(tint: .secondary)
1263

            
1264
                Button {
1265
                    stopSession(
1266
                        session,
Bogdan Timofte authored a month ago
1267
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1268
                    )
1269
                } label: {
1270
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1271
                        .frame(maxWidth: .infinity)
1272
                }
1273
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1274
                .disabled(!isSaveEnabled)
1275
                .help(saveDisabledReason ?? "Close and save this session")
1276

            
1277
                Button("Cancel") {
1278
                    resetStopConfirmation()
1279
                }
1280
                .monitoringPanelActionStyle(tint: .secondary)
1281
            }
1282
        }
1283
        .padding(14)
1284
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1285
    }
1286

            
1287
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1288
        return HStack(spacing: 8) {
1289
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1290
                Button {
1291
                    finalCheckpointMode = mode
1292
                    if mode == .custom {
1293
                        prefillFinalCheckpointIfNeeded(for: session)
1294
                    } else {
1295
                        finalCheckpointText = ""
1296
                    }
1297
                } label: {
1298
                    VStack(spacing: 5) {
1299
                        Image(systemName: mode.icon)
1300
                            .font(.title3)
1301
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1302
                        Text(mode.label)
1303
                            .font(.caption.weight(.semibold))
1304
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1305
                    }
1306
                    .frame(maxWidth: .infinity)
1307
                    .padding(.vertical, 10)
1308
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1309
                    .meterCard(
1310
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1311
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1312
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1313
                        cornerRadius: 12
1314
                    )
1315
                }
1316
                .buttonStyle(.plain)
1317
            }
1318
        }
1319
    }
1320

            
1321
    private var customFinalCheckpointRow: some View {
1322
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1323
            || parsedFinalCheckpoint == nil
1324

            
1325
        return HStack(spacing: 8) {
1326
            Button {
1327
                adjustFinalCheckpoint(by: -1)
1328
            } label: {
1329
                Image(systemName: "minus.circle").font(.title3)
1330
            }
1331
            .buttonStyle(.plain)
1332

            
1333
            TextField("-", text: $finalCheckpointText)
1334
                .keyboardType(.decimalPad)
1335
                .textFieldStyle(.roundedBorder)
1336
                .frame(width: 56)
1337
                .multilineTextAlignment(.center)
1338
                .overlay(
1339
                    RoundedRectangle(cornerRadius: 6)
1340
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1341
                )
1342

            
1343
            Text("%").foregroundColor(.secondary)
1344

            
1345
            Text("required")
1346
                .font(.caption2.weight(.semibold))
1347
                .foregroundColor(isInvalid ? .red : .secondary)
1348

            
1349
            Button {
1350
                adjustFinalCheckpoint(by: 1)
1351
            } label: {
1352
                Image(systemName: "plus.circle").font(.title3)
1353
            }
1354
            .buttonStyle(.plain)
1355

            
1356
            Spacer()
1357
        }
1358
    }
1359

            
1360
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1361
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1362
            if let meterName = session.meterName {
1363
                MeterInfoRowView(label: "Controlled On", value: meterName)
1364
            }
1365
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1366
                .font(.caption2)
1367
                .foregroundColor(.secondary)
1368
        }
1369
    }
1370

            
1371
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1372
        MeterInfoCardView(title: "Administration", tint: .red) {
1373
            Button(role: .destructive) {
1374
                pendingSessionDeletion = session
1375
            } label: {
1376
                Label("Delete Session", systemImage: "trash")
1377
                    .font(.subheadline.weight(.semibold))
1378
                    .frame(maxWidth: .infinity)
1379
                    .padding(.vertical, 10)
1380
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1381
            }
1382
            .buttonStyle(.plain)
1383
        }
1384
    }
1385

            
1386
    @ViewBuilder
1387
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1388
        if let window = detectedTrimWindow {
1389
            HStack(spacing: 12) {
1390
                Image(systemName: "scissors.circle.fill")
1391
                    .font(.title3)
1392
                    .foregroundColor(.blue)
1393

            
1394
                VStack(alignment: .leading, spacing: 2) {
1395
                    Text("Charging ended early")
1396
                        .font(.subheadline.weight(.semibold))
1397
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1398
                        .font(.caption)
1399
                        .foregroundColor(.secondary)
1400
                        .fixedSize(horizontal: false, vertical: true)
1401
                }
1402

            
1403
                Spacer(minLength: 0)
1404

            
1405
                VStack(spacing: 6) {
1406
                    Button("Trim Start") {
1407
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1408
                        trimBannerDismissedForSessionID = session.id
1409
                    }
1410
                    .font(.caption.weight(.semibold))
1411
                    .buttonStyle(.borderedProminent)
1412
                    .controlSize(.small)
1413
                    .tint(.blue)
1414

            
1415
                    Button("End & Finish") {
1416
                        requestStop(
1417
                            session,
1418
                            applyingTrimStart: session.trimStart ?? window.start,
1419
                            trimEnd: window.end,
1420
                            title: "Trim End & Finish",
1421
                            confirmTitle: "Finish",
1422
                            explanation: "The detected charging window will be saved before the session is closed."
1423
                        )
1424
                        trimBannerDismissedForSessionID = session.id
1425
                    }
1426
                    .font(.caption.weight(.semibold))
1427
                    .buttonStyle(.bordered)
1428
                    .controlSize(.small)
1429
                    .tint(.red)
1430
                }
1431
            }
1432
            .padding(14)
1433
            .background(
1434
                RoundedRectangle(cornerRadius: 14)
1435
                    .fill(Color.blue.opacity(0.10))
1436
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1437
            )
1438
            .transition(.opacity.combined(with: .move(edge: .top)))
1439
        }
1440
    }
1441

            
1442
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1443
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1444
    }
1445

            
Bogdan Timofte authored a month ago
1446
    private func chartCard(
1447
        _ session: ChargeSessionSummary,
1448
        chargedDevice: ChargedDeviceSummary
1449
    ) -> some View {
Bogdan Timofte authored a month ago
1450
        ChargeSessionChartCardView(
1451
            session: session,
1452
            monitoringMeter: liveMonitoringMeter,
Bogdan Timofte authored a month ago
1453
            batteryPercentPoints: batteryPercentChartPoints(
1454
                for: session,
1455
                chargedDevice: chargedDevice
1456
            ),
Bogdan Timofte authored a month ago
1457
            controlMode: chartControlMode(for: session),
1458
            onSetTrim: { start, end in
1459
                setSessionTrim(sessionID: session.id, start: start, end: end)
1460
            },
1461
            onStopWithTrim: { start, end in
1462
                requestStop(
1463
                    session,
1464
                    applyingTrimStart: start,
1465
                    trimEnd: end,
1466
                    title: "Trim End & Finish",
1467
                    confirmTitle: "Finish",
1468
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1469
                )
Bogdan Timofte authored a month ago
1470
            },
1471
            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1472
                ? {
1473
                    pendingTrimCommitSession = session
1474
                }
1475
                : nil
Bogdan Timofte authored a month ago
1476
        )
1477
    }
1478

            
Bogdan Timofte authored a month ago
1479
    private func powerbankChartCard(_ session: ChargeSessionSummary) -> some View {
1480
        ChargeSessionChartCardView(
1481
            session: session,
1482
            monitoringMeter: liveMonitoringMeter,
1483
            batteryPercentPoints: batteryPercentChartPoints(forPowerbankSession: session),
1484
            controlMode: chartControlMode(for: session),
1485
            onSetTrim: { start, end in
1486
                setSessionTrim(sessionID: session.id, start: start, end: end)
1487
            },
1488
            onStopWithTrim: { start, end in
1489
                requestStop(
1490
                    session,
1491
                    applyingTrimStart: start,
1492
                    trimEnd: end,
1493
                    title: "Trim End & Finish",
1494
                    confirmTitle: "Finish",
1495
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1496
                )
1497
            },
1498
            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1499
                ? {
1500
                    pendingTrimCommitSession = session
1501
                }
1502
                : nil
1503
        )
1504
    }
1505

            
Bogdan Timofte authored a month ago
1506
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1507
        if hasMonitoringControls {
1508
            return .activeMonitoring
1509
        }
1510

            
1511
        if session.status.isOpen == false {
1512
            return .closed
1513
        }
1514

            
1515
        return .none
1516
    }
1517

            
1518
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1519
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1520
        trimBannerDismissedForSessionID = sessionID
1521
    }
1522

            
Bogdan Timofte authored a month ago
1523
    private func batteryPercentChartPoints(
1524
        for session: ChargeSessionSummary,
1525
        chargedDevice: ChargedDeviceSummary
1526
    ) -> [Measurements.Measurement.Point] {
1527
        var candidates: [BatteryPercentCandidate] = []
1528

            
1529
        for sample in session.displayedAggregatedSamples {
1530
            let percent = chargedDevice.batteryLevelPrediction(
1531
                    for: session,
1532
                    effectiveEnergyWhOverride: effectiveBatteryEnergyWh(
1533
                        rawMeasuredEnergyWh: sample.measuredEnergyWh,
1534
                        for: session
1535
                    ),
1536
                    referenceTimestamp: sample.timestamp
1537
                )?.predictedPercent
1538
                ?? sample.estimatedBatteryPercent
1539

            
1540
            if let percent, percent.isFinite {
1541
                candidates.append(
1542
                    BatteryPercentCandidate(
1543
                        timestamp: sample.timestamp,
1544
                        percent: percent,
1545
                        isCheckpoint: false
1546
                    )
1547
                )
1548
            }
1549
        }
1550

            
1551
        for checkpoint in session.checkpoints where session.effectiveTimeRange.contains(checkpoint.timestamp) {
1552
            guard checkpoint.batteryPercent.isFinite,
1553
                  checkpoint.batteryPercent >= 0,
1554
                  checkpoint.batteryPercent <= 100 else {
1555
                continue
1556
            }
1557
            candidates.append(
1558
                BatteryPercentCandidate(
1559
                    timestamp: checkpoint.timestamp,
1560
                    percent: checkpoint.batteryPercent,
1561
                    isCheckpoint: true
1562
                )
1563
            )
1564
        }
1565

            
1566
        if hasMonitoringControls,
1567
           let prediction = chargedDevice.batteryLevelPrediction(
1568
            for: session,
1569
            effectiveEnergyWhOverride: displayedSessionEnergyWh(for: session)
1570
           ) {
1571
            candidates.append(
1572
                BatteryPercentCandidate(
1573
                    timestamp: max(session.lastObservedAt, Date()),
1574
                    percent: prediction.predictedPercent,
1575
                    isCheckpoint: false
1576
                )
1577
            )
1578
        }
1579

            
1580
        let sortedCandidates = coalescedBatteryPercentCandidates(candidates).sorted { lhs, rhs in
1581
            if lhs.timestamp != rhs.timestamp {
1582
                return lhs.timestamp < rhs.timestamp
1583
            }
1584
            return lhs.isCheckpoint && !rhs.isCheckpoint
1585
        }
1586

            
1587
        var points: [Measurements.Measurement.Point] = []
1588
        var previousCandidate: BatteryPercentCandidate?
1589

            
1590
        for candidate in sortedCandidates {
1591
            if let previousCandidate,
1592
               candidate.timestamp.timeIntervalSince(previousCandidate.timestamp) > 90 {
1593
                points.append(
1594
                    Measurements.Measurement.Point(
1595
                        id: points.count,
1596
                        timestamp: candidate.timestamp,
1597
                        value: points.last?.value ?? candidate.percent,
1598
                        kind: .discontinuity
1599
                    )
1600
                )
1601
            }
1602

            
1603
            points.append(
1604
                Measurements.Measurement.Point(
1605
                    id: points.count,
1606
                    timestamp: candidate.timestamp,
1607
                    value: min(max(candidate.percent, 0), 100)
1608
                )
1609
            )
1610
            previousCandidate = candidate
1611
        }
1612

            
1613
        return points
1614
    }
1615

            
Bogdan Timofte authored a month ago
1616
    private func batteryPercentChartPoints(
1617
        forPowerbankSession session: ChargeSessionSummary
1618
    ) -> [Measurements.Measurement.Point] {
1619
        session.checkpoints
1620
            .filter { session.effectiveTimeRange.contains($0.timestamp) }
1621
            .sorted { $0.timestamp < $1.timestamp }
1622
            .enumerated()
1623
            .map { index, checkpoint in
1624
                Measurements.Measurement.Point(
1625
                    id: index,
1626
                    timestamp: checkpoint.timestamp,
1627
                    value: min(max(checkpoint.batteryPercent, 0), 100)
1628
                )
1629
            }
1630
    }
1631

            
Bogdan Timofte authored a month ago
1632
    private func coalescedBatteryPercentCandidates(
1633
        _ candidates: [BatteryPercentCandidate]
1634
    ) -> [BatteryPercentCandidate] {
1635
        let sortedCandidates = candidates.sorted { lhs, rhs in
1636
            if lhs.timestamp != rhs.timestamp {
1637
                return lhs.timestamp < rhs.timestamp
1638
            }
1639
            return lhs.isCheckpoint && !rhs.isCheckpoint
1640
        }
1641

            
1642
        var coalesced: [BatteryPercentCandidate] = []
1643

            
1644
        for candidate in sortedCandidates {
1645
            if let last = coalesced.last,
1646
               abs(candidate.timestamp.timeIntervalSince(last.timestamp)) <= 1 {
1647
                if candidate.isCheckpoint || !last.isCheckpoint {
1648
                    coalesced[coalesced.count - 1] = candidate
1649
                }
1650
            } else {
1651
                coalesced.append(candidate)
1652
            }
1653
        }
1654

            
1655
        return coalesced
1656
    }
1657

            
1658
    private func effectiveBatteryEnergyWh(
1659
        rawMeasuredEnergyWh: Double,
1660
        for session: ChargeSessionSummary
1661
    ) -> Double {
1662
        switch session.chargingTransportMode {
1663
        case .wired:
1664
            return rawMeasuredEnergyWh
1665
        case .wireless:
1666
            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
1667
                return rawMeasuredEnergyWh * factor
1668
            }
1669
            if let effectiveEnergyWh = session.effectiveBatteryEnergyWh,
1670
               session.measuredEnergyWh > 0 {
1671
                return rawMeasuredEnergyWh * (effectiveEnergyWh / session.measuredEnergyWh)
1672
            }
1673
            return rawMeasuredEnergyWh
1674
        }
1675
    }
1676

            
Bogdan Timofte authored a month ago
1677
    private func requestStop(
1678
        _ session: ChargeSessionSummary,
1679
        applyingTrimStart trimStart: Date?,
1680
        trimEnd: Date?,
1681
        title: String,
1682
        confirmTitle: String,
1683
        explanation: String
1684
    ) {
1685
        pendingSessionStopRequest = ChargeSessionStopRequest(
1686
            sessionID: session.id,
1687
            title: title,
1688
            confirmTitle: confirmTitle,
1689
            explanation: explanation,
1690
            appliesTrim: trimStart != nil || trimEnd != nil,
1691
            trimStart: trimStart,
1692
            trimEnd: trimEnd
1693
        )
1694
    }
1695

            
1696
    private var parsedDraftTarget: Double? {
1697
        let normalized = draftTargetText
1698
            .trimmingCharacters(in: .whitespacesAndNewlines)
1699
            .replacingOccurrences(of: ",", with: ".")
1700
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1701
        return value
1702
    }
1703

            
1704
    private var parsedFinalCheckpoint: Double? {
1705
        let normalized = finalCheckpointText
1706
            .trimmingCharacters(in: .whitespacesAndNewlines)
1707
            .replacingOccurrences(of: ",", with: ".")
1708
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1709
        return value
1710
    }
1711

            
1712
    private var resolvedFinalCheckpoint: Double? {
1713
        switch finalCheckpointMode {
Bogdan Timofte authored a month ago
1714
        case .full:   return suggestedFinalCheckpointPercent(for: session)
Bogdan Timofte authored a month ago
1715
        case .skip:   return nil
1716
        case .custom: return parsedFinalCheckpoint
1717
        }
1718
    }
1719

            
1720
    private func adjustFinalCheckpoint(by delta: Double) {
1721
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1722
        let next = min(max(current + delta, 0), 100)
1723
        finalCheckpointText = next.format(decimalDigits: 0)
1724
    }
1725

            
1726
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1727
        guard let session else { return nil }
1728
        if let endBatteryPercent = session.endBatteryPercent {
1729
            return endBatteryPercent
1730
        }
1731
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1732
            return latestCheckpoint.batteryPercent
1733
        }
1734
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1735
    }
1736

            
1737
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1738
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1739
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1740
            return
1741
        }
1742
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1743
    }
1744

            
1745
    private func hasSavableChargeData(
1746
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1747
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1748
    ) -> Bool {
1749
        session.hasSavableChargeData
1750
            || displayedEnergyWh > 0
1751
    }
1752

            
1753
    private func saveDisabledReason(
1754
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1755
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1756
    ) -> String? {
1757
        if finalCheckpointMode == .custom {
1758
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1759
            if trimmed.isEmpty {
1760
                return "Enter the final battery percentage or choose Skip."
1761
            }
1762
            if parsedFinalCheckpoint == nil {
1763
                return "Final battery percentage must be between 0 and 100."
1764
            }
1765
        }
Bogdan Timofte authored a month ago
1766
        if finalCheckpointMode == .full && resolvedFinalCheckpoint == nil {
1767
            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
1768
        }
Bogdan Timofte authored a month ago
1769

            
1770
        guard hasSavableChargeData(
1771
            session: session,
Bogdan Timofte authored a month ago
1772
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1773
        ) else {
1774
            return "This session has no charging data to save. Discard it instead."
1775
        }
1776

            
1777
        return nil
1778
    }
1779

            
1780
    private func stopSession(
1781
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1782
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1783
    ) {
1784
        stopFailureMessage = nil
1785

            
1786
        if let saveDisabledReason = saveDisabledReason(
1787
            session: session,
Bogdan Timofte authored a month ago
1788
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1789
        ) {
1790
            stopFailureMessage = saveDisabledReason
1791
            return
1792
        }
1793

            
1794
        let didSave = appData.stopChargeSession(
1795
            sessionID: session.id,
1796
            finalBatteryPercent: resolvedFinalCheckpoint,
1797
            from: liveMonitoringMeter
1798
        )
1799
        if didSave {
1800
            resetStopConfirmation()
1801
        } else {
1802
            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."
1803
        }
1804
    }
1805

            
1806
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1807
        finalCheckpointMode = .skip
1808
        finalCheckpointText = ""
1809
        stopFailureMessage = nil
1810
        showingStopConfirm = true
1811
    }
1812

            
1813
    private func discardSession(_ session: ChargeSessionSummary) {
1814
        _ = appData.deleteChargeSession(sessionID: session.id)
1815
        resetStopConfirmation()
1816
    }
1817

            
1818
    private func resetStopConfirmation() {
1819
        showingStopConfirm = false
1820
        finalCheckpointText = ""
1821
        finalCheckpointMode = .skip
1822
        stopFailureMessage = nil
1823
    }
1824

            
1825
    private func syncMonitoringRestore() {
1826
        guard let session,
1827
              session.status.isOpen,
1828
              let liveMonitoringMeter,
1829
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1830
            return
1831
        }
1832
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1833
    }
1834

            
1835
    private func runTrimDetection() {
1836
        guard hasMonitoringControls,
1837
              let session,
1838
              session.isTrimmed == false,
1839
              !session.aggregatedSamples.isEmpty else {
1840
            detectedTrimWindow = nil
1841
            return
1842
        }
1843

            
1844
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1845
        detectedTrimWindow = ChargingWindowDetector.detect(
1846
            samples: session.aggregatedSamples,
1847
            sessionStart: session.startedAt,
1848
            sessionEnd: sessionEnd
1849
        )
1850
    }
1851

            
1852
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1853
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1854
        guard session.isTrimmed == false else { return storedEnergyWh }
1855
        guard session.status.isOpen else { return storedEnergyWh }
1856
        guard let liveMonitoringMeter else { return storedEnergyWh }
1857
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1858
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1859
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1860
        }
1861
        return storedEnergyWh
1862
    }
1863

            
1864
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1865
        let storedDuration = max(session.effectiveDuration, 0)
1866
        guard session.isTrimmed == false else { return storedDuration }
1867
        guard session.status.isOpen else { return storedDuration }
1868
        guard let liveMonitoringMeter else { return storedDuration }
1869
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1870
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1871
    }
1872

            
1873
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1874
        let displayedDuration = displayedSessionDuration(for: session)
1875
        let formatter = DateComponentsFormatter()
1876
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1877
        formatter.unitsStyle = .abbreviated
1878
        formatter.zeroFormattingBehavior = .dropAll
1879
        return formatter.string(from: displayedDuration) ?? "0m"
1880
    }
1881

            
1882
    private func formatDuration(_ duration: TimeInterval) -> String {
1883
        let totalSeconds = Int(duration.rounded(.down))
1884
        let hours = totalSeconds / 3600
1885
        let minutes = (totalSeconds % 3600) / 60
1886
        let seconds = totalSeconds % 60
1887
        if hours > 0 {
1888
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1889
        }
1890
        return String(format: "%02d:%02d", minutes, seconds)
1891
    }
1892

            
1893
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1894
        if session.autoStopEnabled == false {
1895
            return "Manual"
1896
        }
1897

            
1898
        if let sessionWarning = sessionWarning(for: session),
1899
           sessionWarning.contains("idle-current") {
1900
            return "Blocked by charger setup"
1901
        }
1902

            
1903
        if session.stopThresholdAmps > 0 {
1904
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1905
        }
1906

            
1907
        return "Learning"
1908
    }
1909

            
1910
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1911
        if session.autoStopEnabled == false {
1912
            return "Manual"
1913
        }
1914
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1915
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1916
        }
1917
        if session.stopThresholdAmps > 0 {
1918
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1919
        }
1920
        return "Learning"
1921
    }
1922

            
1923
    private func shouldShowChargingTransport(
1924
        for session: ChargeSessionSummary,
1925
        chargedDevice: ChargedDeviceSummary
1926
    ) -> Bool {
1927
        chargedDevice.supportedChargingModes.count > 1
1928
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1929
    }
1930

            
1931
    private func shouldShowChargingState(
1932
        for session: ChargeSessionSummary,
1933
        chargedDevice: ChargedDeviceSummary
1934
    ) -> Bool {
1935
        chargedDevice.supportedChargingStateModes.count > 1
1936
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1937
    }
1938

            
1939
    private func batteryColor(for percent: Double) -> Color {
1940
        if percent >= 75 { return .green }
1941
        if percent >= 35 { return .orange }
1942
        return .red
1943
    }
1944

            
1945
    private func etaText(
1946
        rateWhPerSec: Double?,
1947
        remainingWh: Double,
1948
        isRelevant: Bool
1949
    ) -> String? {
1950
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1951
        let seconds = remainingWh / rateWhPerSec
1952
        return seconds > 120 ? formatETA(seconds) : nil
1953
    }
1954

            
1955
    private func etaToTargetText(
1956
        session: ChargeSessionSummary,
1957
        prediction: BatteryLevelPrediction,
1958
        displayedEnergyWh: Double,
1959
        rateWhPerSec: Double?
1960
    ) -> String? {
1961
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1962
            return nil
1963
        }
Bogdan Timofte authored a month ago
1964
        guard let targetEnergyWh = prediction.energyWh(forPercent: target) else {
1965
            return nil
1966
        }
Bogdan Timofte authored a month ago
1967
        return etaText(
1968
            rateWhPerSec: rateWhPerSec,
1969
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1970
            isRelevant: true
1971
        )
1972
    }
1973

            
Bogdan Timofte authored a month ago
1974
    private func batteryPredictionExplanation(_ prediction: BatteryLevelPrediction) -> String {
1975
        let anchor = "Anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%"
1976
        guard let estimatedCapacityWh = prediction.estimatedCapacityWh else {
1977
            return "\(anchor)."
1978
        }
1979
        return "\(anchor) using \(estimatedCapacityWh.format(decimalDigits: 2)) Wh \(prediction.basis.explanatoryLabel)."
1980
    }
1981

            
Bogdan Timofte authored a month ago
1982
    private func formatETA(_ seconds: TimeInterval) -> String {
1983
        let totalMinutes = Int(seconds / 60)
1984
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1985
        let hours = totalMinutes / 60
1986
        let minutes = totalMinutes % 60
1987
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1988
    }
1989

            
1990
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1991
        switch session.status {
1992
        case .active:
1993
            return .red
1994
        case .paused:
1995
            return .orange
1996
        case .completed:
1997
            return .green
1998
        case .abandoned:
1999
            return .secondary
2000
        }
2001
    }
2002

            
2003
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
2004
        nil
Bogdan Timofte authored a month ago
2005
    }
2006

            
2007
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
2008
        guard session.chargingTransportMode == .wireless else {
2009
            return nil
2010
        }
2011

            
2012
        var components: [String] = []
2013
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
2014
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
2015
        }
2016
        if session.usesEstimatedWirelessEfficiency {
2017
            components.append("Estimated from wired baseline and checkpoints")
2018
        }
2019
        if session.shouldWarnAboutLowWirelessEfficiency {
2020
            components.append("Low wireless efficiency, so capacity confidence is reduced")
2021
        }
2022

            
2023
        return components.isEmpty ? nil : components.joined(separator: " - ")
2024
    }
2025

            
2026
    private func statusTint(for session: ChargeSessionSummary) -> Color {
2027
        switch session.status {
2028
        case .active:
2029
            return .green
2030
        case .paused:
2031
            return .orange
2032
        case .completed:
2033
            return .teal
2034
        case .abandoned:
2035
            return .secondary
2036
        }
2037
    }
2038
}
2039

            
2040
enum ChargeSessionChartControlMode {
2041
    case none
2042
    case activeMonitoring
2043
    case closed
2044
}
2045

            
2046
struct ChargeSessionChartCardView: View {
2047
    let session: ChargeSessionSummary
2048
    let monitoringMeter: Meter?
Bogdan Timofte authored a month ago
2049
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
2050
    let controlMode: ChargeSessionChartControlMode
2051
    let onSetTrim: (Date?, Date?) -> Void
2052
    let onStopWithTrim: (Date?, Date?) -> Void
Bogdan Timofte authored a month ago
2053
    let onCommitTrim: (() -> Void)?
Bogdan Timofte authored a month ago
2054

            
2055
    @StateObject private var storedMeasurements = Measurements()
2056

            
2057
    private var chartMeasurements: Measurements {
2058
        if let monitoringMeter,
2059
           session.status.isOpen,
2060
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
2061
            return monitoringMeter.chargeRecordMeasurements
2062
        }
2063
        return storedMeasurements
2064
    }
2065

            
2066
    private var fullTimeRange: ClosedRange<Date> {
2067
        let start = session.startedAt
2068
        let end = max(session.endedAt ?? session.lastObservedAt, start)
2069
        return start...end
2070
    }
2071

            
2072
    private var fixedTimeRange: ClosedRange<Date>? {
2073
        if monitoringMeter != nil && session.status.isOpen {
2074
            return nil
2075
        }
2076
        return session.effectiveTimeRange
2077
    }
2078

            
2079
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
2080
        guard monitoringMeter != nil && session.status.isOpen else {
2081
            return (nil, nil)
2082
        }
2083
        return (session.trimStart, session.trimEnd)
2084
    }
2085

            
2086
    private var showsRangeSelector: Bool {
2087
        controlMode != .none && !session.aggregatedSamples.isEmpty
2088
    }
2089

            
2090
    var body: some View {
2091
        VStack(alignment: .leading, spacing: 12) {
2092
            HStack(spacing: 8) {
2093
                Image(systemName: "chart.xyaxis.line")
2094
                    .foregroundColor(.blue)
2095
                Text("Session Chart")
2096
                    .font(.headline)
2097
                ContextInfoButton(
2098
                    title: "Session Chart",
2099
                    message: chartInfoMessage
2100
                )
2101
                Spacer(minLength: 0)
2102
            }
2103

            
2104
            MeasurementChartView(
2105
                timeRange: fixedTimeRange,
2106
                timeRangeLowerBound: liveTrimBounds.lower,
2107
                timeRangeUpperBound: liveTrimBounds.upper,
2108
                showsRangeSelector: showsRangeSelector,
2109
                rebasesEnergyToVisibleRangeStart: true,
2110
                extendsTimelineToPresent: false,
2111
                showsTemperatureSeries: false,
Bogdan Timofte authored a month ago
2112
                showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
2113
                batteryCheckpoints: session.checkpoints,
2114
                batteryPercentPoints: batteryPercentPoints,
Bogdan Timofte authored a month ago
2115
                rangeSelectorConfiguration: rangeSelectorConfiguration
2116
            )
2117
            .environmentObject(chartMeasurements)
2118
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
2119

            
2120
            if let onCommitTrim {
2121
                Divider()
2122

            
2123
                HStack(alignment: .center, spacing: 10) {
2124
                    Label("Save trim permanently", systemImage: "internaldrive")
2125
                        .font(.caption.weight(.semibold))
2126
                        .foregroundColor(.secondary)
2127

            
2128
                    Spacer(minLength: 0)
2129

            
2130
                    Button {
2131
                        onCommitTrim()
2132
                    } label: {
2133
                        Label("Save Trim", systemImage: "checkmark.seal")
2134
                            .font(.caption.weight(.semibold))
2135
                    }
2136
                    .buttonStyle(.borderedProminent)
2137
                    .controlSize(.small)
2138
                    .tint(.red)
2139
                }
2140
            }
Bogdan Timofte authored a month ago
2141
        }
2142
        .padding(18)
2143
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
2144
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
2145
        .onChange(of: session.id) { _ in
2146
            restoreStoredMeasurementsIfNeeded()
2147
        }
2148
        .onChange(of: session.aggregatedSamples.count) { _ in
2149
            restoreStoredMeasurementsIfNeeded()
2150
        }
Bogdan Timofte authored a month ago
2151
        .onChange(of: session.checkpoints.count) { _ in
2152
            restoreStoredMeasurementsIfNeeded()
2153
        }
Bogdan Timofte authored a month ago
2154
    }
2155

            
2156
    private var chartInfoMessage: String {
2157
        if monitoringMeter != nil && session.status.isOpen {
2158
            return "This chart combines the persisted session curve with current live data from this meter."
2159
        }
2160

            
2161
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
2162
    }
2163

            
Bogdan Timofte authored a month ago
2164
    private var shouldShowBatteryPercentSeries: Bool {
2165
        !batteryPercentPoints.isEmpty
2166
    }
2167

            
Bogdan Timofte authored a month ago
2168
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
2169
        switch controlMode {
2170
        case .none:
2171
            return nil
2172
        case .activeMonitoring:
2173
            return MeasurementChartRangeSelectorConfiguration(
2174
                keepAction: MeasurementChartSelectionAction(
2175
                    title: "Trim Start",
2176
                    shortTitle: "Start",
2177
                    systemName: "arrow.right.to.line",
2178
                    tone: .destructive,
2179
                    handler: applyActiveStartTrim
2180
                ),
2181
                removeAction: MeasurementChartSelectionAction(
2182
                    title: "Trim End & Finish",
2183
                    shortTitle: "End",
2184
                    systemName: "arrow.left.to.line",
2185
                    tone: .destructiveProminent,
2186
                    handler: requestActiveEndTrim
2187
                ),
2188
                resetAction: MeasurementChartResetAction(
2189
                    title: "Reset Trim",
2190
                    shortTitle: "Reset",
2191
                    systemName: "arrow.counterclockwise",
2192
                    tone: .reversible,
2193
                    confirmationTitle: "Reset session trim?",
2194
                    confirmationButtonTitle: "Reset trim",
2195
                    handler: {
2196
                        onSetTrim(nil, nil)
2197
                    }
Bogdan Timofte authored a month ago
2198
                ),
2199
                exportAction: sessionCSVExportAction
Bogdan Timofte authored a month ago
2200
            )
2201
        case .closed:
2202
            return MeasurementChartRangeSelectorConfiguration(
2203
                keepAction: MeasurementChartSelectionAction(
2204
                    title: "Trim Window",
2205
                    shortTitle: "Trim",
2206
                    systemName: "scissors",
2207
                    tone: .destructive,
2208
                    handler: applyClosedTrim
2209
                ),
2210
                removeAction: nil,
2211
                resetAction: MeasurementChartResetAction(
2212
                    title: "Reset Trim",
2213
                    shortTitle: "Reset",
2214
                    systemName: "arrow.counterclockwise",
2215
                    tone: .reversible,
2216
                    confirmationTitle: "Reset session trim?",
2217
                    confirmationButtonTitle: "Reset trim",
2218
                    handler: {
2219
                        onSetTrim(nil, nil)
2220
                    }
Bogdan Timofte authored a month ago
2221
                ),
2222
                exportAction: sessionCSVExportAction
Bogdan Timofte authored a month ago
2223
            )
2224
        }
2225
    }
2226

            
Bogdan Timofte authored a month ago
2227
    private var sessionCSVExportAction: MeasurementChartExportAction {
2228
        MeasurementChartExportAction(
2229
            title: "Export CSV",
2230
            shortTitle: "CSV",
2231
            systemName: "square.and.arrow.up",
2232
            tone: .reversible,
2233
            fileName: sessionCSVFileName,
2234
            content: sessionCSVContent
2235
        )
2236
    }
2237

            
2238
    private func sessionCSVFileName(for range: ClosedRange<Date>) -> String {
2239
        let formatter = DateFormatter()
2240
        formatter.locale = Locale(identifier: "en_US_POSIX")
2241
        formatter.timeZone = .current
2242
        formatter.dateFormat = "yyyyMMdd-HHmmss"
2243

            
2244
        return [
2245
            "charge-session",
2246
            formatter.string(from: range.lowerBound),
2247
            formatter.string(from: range.upperBound)
2248
        ].joined(separator: "-")
2249
    }
2250

            
2251
    private func sessionCSVContent(for range: ClosedRange<Date>) -> String {
2252
        let samples = session.aggregatedSamples
2253
            .filter { range.contains($0.timestamp) }
2254
            .sorted { lhs, rhs in
2255
                if lhs.bucketIndex != rhs.bucketIndex {
2256
                    return lhs.bucketIndex < rhs.bucketIndex
2257
                }
2258
                return lhs.timestamp < rhs.timestamp
2259
            }
2260

            
2261
        let timestampFormatter = ISO8601DateFormatter()
2262
        timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
2263

            
2264
        var rows: [[String]] = [
2265
            [
2266
                "Timestamp",
2267
                "Elapsed Seconds",
2268
                "Voltage (V)",
2269
                "Current (A)",
2270
                "Power (W)",
2271
                "Session Energy (Wh)",
2272
                "Interval Energy (Wh)",
2273
                "Battery (%)",
2274
                "Sample Count"
2275
            ]
2276
        ]
2277

            
2278
        let intervalEnergyBaseline = samples.first?.measuredEnergyWh ?? 0
2279
        for sample in samples {
2280
            rows.append([
2281
                timestampFormatter.string(from: sample.timestamp),
2282
                formattedCSVNumber(sample.timestamp.timeIntervalSince(session.startedAt), fractionDigits: 3),
2283
                formattedCSVNumber(sample.averageVoltageVolts, fractionDigits: 6),
2284
                formattedCSVNumber(sample.averageCurrentAmps, fractionDigits: 6),
2285
                formattedCSVNumber(sample.averagePowerWatts, fractionDigits: 6),
2286
                formattedCSVNumber(sample.measuredEnergyWh, fractionDigits: 6),
2287
                formattedCSVNumber(max(sample.measuredEnergyWh - intervalEnergyBaseline, 0), fractionDigits: 6),
2288
                formattedCSVNumber(sample.estimatedBatteryPercent, fractionDigits: 3),
2289
                "\(sample.sampleCount)"
2290
            ])
2291
        }
2292

            
2293
        return rows
2294
            .map { row in row.map(escapedCSVField).joined(separator: ",") }
2295
            .joined(separator: "\n")
2296
    }
2297

            
2298
    private func formattedCSVNumber(_ value: Double?, fractionDigits: Int) -> String {
2299
        guard let value, value.isFinite else { return "" }
2300
        return String(
2301
            format: "%.\(fractionDigits)f",
2302
            locale: Locale(identifier: "en_US_POSIX"),
2303
            value
2304
        )
2305
    }
2306

            
2307
    private func escapedCSVField(_ field: String) -> String {
2308
        let mustQuote = field.contains(",") || field.contains("\"") || field.contains("\n")
2309
        guard mustQuote else { return field }
2310
        return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
2311
    }
2312

            
Bogdan Timofte authored a month ago
2313
    private func restoreStoredMeasurementsIfNeeded() {
2314
        guard monitoringMeter == nil || session.status.isOpen == false else {
2315
            return
2316
        }
2317
        storedMeasurements.resetSeries()
2318
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
2319
            from: session,
2320
            replacingLiveBufferIfNeeded: true
2321
        )
2322
    }
2323

            
2324
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
2325
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
2326
    }
2327

            
2328
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
2329
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
2330
        let end = normalizedEnd(range.upperBound)
2331
        onStopWithTrim(start, end)
2332
    }
2333

            
2334
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
2335
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
2336
    }
2337

            
2338
    private func normalizedStart(_ date: Date) -> Date? {
2339
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
2340
    }
2341

            
2342
    private func normalizedEnd(_ date: Date) -> Date? {
2343
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
2344
    }
2345
}
2346

            
2347
private struct ChargeSessionStopRequest: Identifiable {
2348
    let sessionID: UUID
2349
    let title: String
2350
    let confirmTitle: String
2351
    let explanation: String
2352
    let appliesTrim: Bool
2353
    let trimStart: Date?
2354
    let trimEnd: Date?
2355

            
2356
    var id: String {
2357
        [
2358
            sessionID.uuidString,
2359
            title,
2360
            trimStart?.timeIntervalSince1970.description ?? "nil",
2361
            trimEnd?.timeIntervalSince1970.description ?? "nil"
2362
        ].joined(separator: "-")
2363
    }
2364
}
2365

            
2366
private extension View {
2367
    func monitoringActionStyle(tint: Color) -> some View {
2368
        frame(maxWidth: .infinity)
2369
            .padding(.vertical, 10)
2370
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
2371
            .buttonStyle(.plain)
2372
    }
2373

            
2374
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
2375
        frame(maxWidth: .infinity)
2376
            .padding(.vertical, 9)
2377
            .meterCard(
2378
                tint: tint,
2379
                fillOpacity: isProminent ? 0.22 : 0.10,
2380
                strokeOpacity: isProminent ? 0.32 : 0.14,
2381
                cornerRadius: 14
2382
            )
2383
            .buttonStyle(.plain)
2384
    }
2385
}