Newer Older
1756 lines | 69.33kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeSessionDetailView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 22/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
enum ChargeSessionDetailPresentation {
11
    case navigation
12
    case embedded
13
}
14

            
15
struct ChargeSessionDetailView: View {
16
    private enum FinalCheckpoint: Hashable {
17
        case full
18
        case skip
19
        case custom
20

            
21
        var label: String {
22
            switch self {
23
            case .full:   return "Full"
24
            case .skip:   return "Skip"
25
            case .custom: return "Other %"
26
            }
27
        }
28

            
29
        var icon: String {
30
            switch self {
31
            case .full:   return "battery.100percent"
32
            case .skip:   return "minus.circle"
33
            case .custom: return "pencil"
34
            }
35
        }
36
    }
37

            
38
    @EnvironmentObject private var appData: AppData
39

            
40
    let chargedDeviceID: UUID
41
    let sessionID: UUID
42
    let monitoringMeter: Meter?
43
    let presentation: ChargeSessionDetailPresentation
44

            
45
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
46
    @State private var pendingSessionDeletion: ChargeSessionSummary?
47
    @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
48
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
49
    @State private var trimBannerDismissedForSessionID: UUID?
50
    @State private var showingInlineTargetEditor = false
51
    @State private var draftTargetText = ""
52
    @State private var showingStopConfirm = false
53
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
54
    @State private var finalCheckpointText = ""
55
    @State private var stopFailureMessage: String?
56

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

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

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

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

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

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

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

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

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

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

            
191
                    if shouldShowTrimBanner {
192
                        trimDetectionBanner(session)
193
                    }
194

            
195
                    if shouldShowSessionChart(session) {
196
                        chartCard(session)
197
                    }
198
                } else {
199
                    overviewCard(session, chargedDevice: chargedDevice)
200
                    energyCard(session, chargedDevice: chargedDevice)
201
                    observedMetricsCard(session, chargedDevice: chargedDevice)
202
                    batteryCard(session, chargedDevice: chargedDevice)
203

            
204
                    if shouldShowSessionChart(session) {
205
                        chartCard(session)
206
                    }
207

            
208
                    if session.status.isOpen {
209
                        followerNoticeCard(session)
210
                    } else {
211
                        managementCard(session)
212
                    }
213
                }
214
            }
215
            .padding(presentation == .embedded ? 16 : 20)
216
        }
217
        .background(
218
            LinearGradient(
219
                colors: [statusTint(for: session).opacity(0.14), Color.clear],
220
                startPoint: .topLeading,
221
                endPoint: .bottomTrailing
222
            )
223
            .ignoresSafeArea()
224
        )
225
        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
226
    }
227

            
228
    private var unavailableState: some View {
229
        VStack(spacing: 12) {
230
            Image(systemName: "bolt.slash")
231
                .font(.title2)
232
                .foregroundColor(.secondary)
233
            Text("This session is no longer available.")
234
                .font(.headline)
235
            Text("It may have been deleted or synced from another device.")
236
                .font(.footnote)
237
                .foregroundColor(.secondary)
238
                .multilineTextAlignment(.center)
239
        }
240
        .frame(maxWidth: .infinity, maxHeight: .infinity)
241
        .padding(24)
242
        .navigationTitle("Session")
243
    }
244

            
245
    private func monitoringSessionCard(
246
        _ session: ChargeSessionSummary,
247
        chargedDevice: ChargedDeviceSummary
248
    ) -> some View {
249
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
250
        let displayedChargeAh = displayedSessionChargeAh(for: session)
251
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
252
            for: session,
253
            effectiveEnergyWhOverride: displayedEnergyWh
254
        )
255

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

            
261
                Spacer()
262

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

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

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

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

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

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

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

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

            
325
            targetSectionView(
326
                session: session,
327
                predictedPercent: batteryPrediction?.predictedPercent
328
            )
329

            
330
            if showingStopConfirm {
331
                stopConfirmPanel(
332
                    session: session,
333
                    displayedEnergyWh: displayedEnergyWh,
334
                    displayedChargeAh: displayedChargeAh
335
                )
336
            } else {
337
                monitoringActionRow(session)
338
            }
339
        }
340
        .padding(18)
341
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
342
    }
343

            
344
    private func overviewCard(
345
        _ session: ChargeSessionSummary,
346
        chargedDevice: ChargedDeviceSummary
347
    ) -> some View {
348
        MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
349
            MeterInfoRowView(label: "Device", value: chargedDevice.name)
350
            MeterInfoRowView(label: "Status", value: session.status.title)
351
            MeterInfoRowView(label: "Started", value: session.startedAt.format())
352
            if let endedAt = session.endedAt {
353
                MeterInfoRowView(label: "Ended", value: endedAt.format())
354
            }
355
            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
356
            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
357
            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
358
            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
359
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
360
            if session.isTrimmed {
361
                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
362
                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
363
            }
364
            if let meterName = session.meterName {
365
                MeterInfoRowView(label: "Meter", value: meterName)
366
            } else if let meterMACAddress = session.meterMACAddress {
367
                MeterInfoRowView(label: "Meter", value: meterMACAddress)
368
            }
369
            if let meterModel = session.meterModel {
370
                MeterInfoRowView(label: "Meter Model", value: meterModel)
371
            }
372
        }
373
    }
374

            
375
    private func energyCard(
376
        _ session: ChargeSessionSummary,
377
        chargedDevice: ChargedDeviceSummary
378
    ) -> some View {
379
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
380
        let displayedChargeAh = displayedSessionChargeAh(for: session)
381

            
382
        return MeterInfoCardView(title: "Energy", tint: .teal) {
383
            MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
384
            if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
385
                MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
386
            }
387
            MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah")
388
            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
389
               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
390
                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
391
            }
392
            if let capacityEstimateWh = session.capacityEstimateWh {
393
                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
394
            }
395
            if let chargerID = session.chargerID,
396
               let charger = appData.chargedDeviceSummary(id: chargerID) {
397
                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
398
            }
399
            if let wirelessSessionHint = wirelessSessionHint(for: session) {
400
                Text(wirelessSessionHint)
401
                    .font(.caption2)
402
                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
403
            }
404
            if let sessionWarning = sessionWarning(for: session) {
405
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
406
                    .font(.caption2)
407
                    .foregroundColor(.orange)
408
            }
409
        }
410
    }
411

            
412
    private func observedMetricsCard(
413
        _ session: ChargeSessionSummary,
414
        chargedDevice: ChargedDeviceSummary
415
    ) -> some View {
416
        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
417
            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
418
                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
419
            }
420
            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
421
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
422
            }
423
            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
424
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
425
            }
426
            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
427
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
428
            }
429
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
430
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
431
            }
432
            if let completionCurrentAmps = session.completionCurrentAmps {
433
                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
434
            }
435
            if session.selectedDataGroup != nil {
436
                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
437
            }
438
        }
439
    }
440

            
441
    private func batteryCard(
442
        _ session: ChargeSessionSummary,
443
        chargedDevice: ChargedDeviceSummary
444
    ) -> some View {
445
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
446
        let displayedChargeAh = displayedSessionChargeAh(for: session)
447
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
448
            for: session,
449
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
450
        )
451

            
452
        return MeterInfoCardView(title: "Battery", tint: .orange) {
453
            if let startBatteryPercent = session.startBatteryPercent {
454
                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
455
            }
456
            if let endBatteryPercent = session.endBatteryPercent {
457
                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
458
            }
459
            if let batteryDeltaPercent = session.batteryDeltaPercent {
460
                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
461
            }
462
            if let targetBatteryPercent = session.targetBatteryPercent {
463
                MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%")
464
            }
465
            if let batteryPrediction {
466
                MeterInfoRowView(
467
                    label: "Predicted Battery",
468
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
469
                )
470
                Text(
471
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
472
                )
473
                .font(.caption2)
474
                .foregroundColor(.secondary)
475
            }
476

            
477
            BatteryCheckpointSectionView(
478
                sessionID: session.id,
479
                checkpoints: session.checkpoints,
480
                message: session.status.isOpen
481
                    ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
482
                    : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
483
                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
484
                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
485
                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
486
                effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
487
                measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil,
488
                onDelete: { checkpoint in
489
                    pendingCheckpointDeletion = checkpoint
490
                }
491
            )
492
        }
493
    }
494

            
495
    private func batteryGaugeSection(
496
        prediction: BatteryLevelPrediction,
497
        session: ChargeSessionSummary,
498
        displayedEnergyWh: Double
499
    ) -> some View {
500
        let percent = prediction.predictedPercent
501
        let color = batteryColor(for: percent)
502
        let duration = displayedSessionDuration(for: session)
503
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
504
            ? displayedEnergyWh / duration
505
            : nil
506
        let etaToFull = etaText(
507
            rateWhPerSec: rateWhPerSec,
508
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
509
            isRelevant: percent < 98
510
        )
511
        let etaToTarget = etaToTargetText(
512
            session: session,
513
            prediction: prediction,
514
            displayedEnergyWh: displayedEnergyWh,
515
            rateWhPerSec: rateWhPerSec
516
        )
517

            
518
        return VStack(spacing: 10) {
519
            HStack(alignment: .lastTextBaseline, spacing: 8) {
520
                HStack(alignment: .lastTextBaseline, spacing: 3) {
521
                    Text("\(Int(percent.rounded()))")
522
                        .font(.system(size: 52, weight: .bold, design: .rounded))
523
                        .foregroundColor(color)
524
                        .monospacedDigit()
525
                    Text("%")
526
                        .font(.title2.weight(.semibold))
527
                        .foregroundColor(color.opacity(0.8))
528
                }
529

            
530
                Spacer()
531

            
532
                VStack(alignment: .trailing, spacing: 2) {
533
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
534
                        .font(.callout.weight(.bold))
535
                        .foregroundColor(.orange)
536
                        .monospacedDigit()
537
                    Text("est. capacity")
538
                        .font(.caption2)
539
                        .foregroundColor(.secondary)
540
                }
541
            }
542

            
543
            batteryProgressBar(
544
                percent: percent,
545
                startPercent: session.startBatteryPercent,
546
                targetPercent: session.targetBatteryPercent
547
            )
548

            
549
            HStack(spacing: 14) {
550
                if let etaToFull {
551
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
552
                }
553

            
554
                if let etaToTarget, let target = session.targetBatteryPercent {
555
                    etaPill(
556
                        icon: "bell.badge.fill",
557
                        tint: .indigo,
558
                        value: etaToTarget,
559
                        label: "to \(Int(target.rounded()))%"
560
                    )
561
                }
562

            
563
                Spacer()
564

            
565
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
566
                    .font(.caption2)
567
                    .foregroundColor(.secondary)
568
                    .multilineTextAlignment(.trailing)
569
            }
570
        }
571
        .padding(14)
572
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
573
    }
574

            
575
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
576
        VStack(alignment: .leading, spacing: 1) {
577
            HStack(spacing: 4) {
578
                Image(systemName: icon)
579
                    .font(.caption)
580
                    .foregroundColor(tint)
581
                Text(value)
582
                    .font(.caption.weight(.bold))
583
            }
584
            Text(label)
585
                .font(.caption2)
586
                .foregroundColor(.secondary)
587
        }
588
    }
589

            
590
    private func batteryProgressBar(
591
        percent: Double,
592
        startPercent: Double?,
593
        targetPercent: Double?
594
    ) -> some View {
595
        let color = batteryColor(for: percent)
596
        return GeometryReader { geo in
597
            let width = geo.size.width
598
            ZStack(alignment: .leading) {
599
                Capsule()
600
                    .fill(Color.primary.opacity(0.10))
601
                Rectangle()
602
                    .fill(
603
                        LinearGradient(
604
                            colors: [color.opacity(0.6), color],
605
                            startPoint: .leading,
606
                            endPoint: .trailing
607
                        )
608
                    )
609
                    .frame(width: max(width * CGFloat(percent / 100), 4))
610
                    .animation(.easeInOut(duration: 0.4), value: percent)
611
                if let start = startPercent, start > 2, start < 98 {
612
                    Rectangle()
613
                        .fill(Color.white.opacity(0.55))
614
                        .frame(width: 2, height: 20)
615
                        .offset(x: width * CGFloat(start / 100) - 1)
616
                }
617
                if let target = targetPercent {
618
                    Rectangle()
619
                        .fill(Color.indigo.opacity(0.9))
620
                        .frame(width: 2.5, height: 20)
621
                        .offset(x: width * CGFloat(target / 100) - 1.25)
622
                }
623
            }
624
            .clipShape(Capsule())
625
        }
626
        .frame(height: 20)
627
    }
628

            
629
    private func sessionMetricsGrid(
630
        session: ChargeSessionSummary,
631
        chargedDevice: ChargedDeviceSummary,
632
        displayedEnergyWh: Double,
633
        hasPrediction: Bool
634
    ) -> some View {
635
        let capacityFallback: Double? = hasPrediction ? nil : (
636
            session.capacityEstimateWh
637
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
638
                ?? chargedDevice.estimatedBatteryCapacityWh
639
        )
640
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
641

            
642
        return LazyVGrid(columns: columns, spacing: 8) {
643
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
644
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
645

            
646
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
647
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
648
            }
649
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
650
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
651
            }
652

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

            
655
            if let capacityFallback {
656
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
657
            }
658
        }
659
    }
660

            
661
    private func metricCell(label: String, value: String, tint: Color) -> some View {
662
        VStack(alignment: .leading, spacing: 3) {
663
            Text(label)
664
                .font(.caption2)
665
                .foregroundColor(.secondary)
666
            Text(value)
667
                .font(.subheadline.weight(.semibold))
668
                .lineLimit(1)
669
                .minimumScaleFactor(0.7)
670
                .monospacedDigit()
671
        }
672
        .frame(maxWidth: .infinity, alignment: .leading)
673
        .padding(.horizontal, 12)
674
        .padding(.vertical, 10)
675
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
676
    }
677

            
678
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
679
        VStack(alignment: .leading, spacing: 10) {
680
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
681
                .font(.subheadline.weight(.semibold))
682

            
683
            if let contradictionPercent = session.completionContradictionPercent {
684
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
685
                    .font(.caption)
686
                    .foregroundColor(.secondary)
687
            } else {
688
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
689
                    .font(.caption)
690
                    .foregroundColor(.secondary)
691
            }
692

            
693
            HStack(spacing: 10) {
694
                Button("Finish") {
695
                    beginStopConfirmation(for: session)
696
                }
697
                .frame(maxWidth: .infinity)
698
                .padding(.vertical, 9)
699
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
700
                .buttonStyle(.plain)
701

            
702
                Button("Keep Monitoring") {
703
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
704
                }
705
                .frame(maxWidth: .infinity)
706
                .padding(.vertical, 9)
707
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
708
                .buttonStyle(.plain)
709
            }
710
        }
711
        .padding(14)
712
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
713
    }
714

            
715
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
716
        let draftBelowPrediction: Bool = {
717
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
718
            return draft <= predictedPercent
719
        }()
720
        let savedBelowPrediction: Bool = {
721
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
722
            return saved <= predictedPercent
723
        }()
724

            
725
        return HStack(alignment: .center, spacing: 8) {
726
            Image(systemName: "bell.badge")
727
                .foregroundColor(.indigo)
728
                .font(.subheadline)
729

            
730
            Text("Notify at")
731
                .font(.subheadline.weight(.semibold))
732

            
733
            Spacer(minLength: 8)
734

            
735
            if showingInlineTargetEditor {
736
                targetEditorControls(
737
                    session: session,
738
                    draftBelowPrediction: draftBelowPrediction,
739
                    predictedPercent: predictedPercent
740
                )
741
            } else {
742
                savedTargetControls(
743
                    session: session,
744
                    savedBelowPrediction: savedBelowPrediction,
745
                    predictedPercent: predictedPercent
746
                )
747
            }
748
        }
749
    }
750

            
751
    private func targetEditorControls(
752
        session: ChargeSessionSummary,
753
        draftBelowPrediction: Bool,
754
        predictedPercent: Double?
755
    ) -> some View {
756
        Group {
757
            Button {
758
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
759
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
760
            } label: {
761
                Image(systemName: "minus.circle")
762
                    .font(.title3)
763
            }
764
            .buttonStyle(.plain)
765

            
766
            TextField("-", text: $draftTargetText)
767
                .keyboardType(.decimalPad)
768
                .textFieldStyle(.roundedBorder)
769
                .frame(width: 48)
770
                .multilineTextAlignment(.center)
771
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
772

            
773
            Text("%")
774
                .font(.subheadline)
775
                .foregroundColor(.secondary)
776

            
777
            if draftBelowPrediction, let predictedPercent {
778
                predictionWarningButton(predictedPercent: predictedPercent)
779
            }
780

            
781
            Button {
782
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
783
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
784
            } label: {
785
                Image(systemName: "plus.circle")
786
                    .font(.title3)
787
            }
788
            .buttonStyle(.plain)
789

            
790
            Button {
791
                if let value = parsedDraftTarget {
792
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
793
                }
794
                showingInlineTargetEditor = false
795
            } label: {
796
                Image(systemName: "checkmark.circle.fill")
797
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
798
                    .font(.title3)
799
            }
800
            .buttonStyle(.plain)
801
            .disabled(parsedDraftTarget == nil)
802

            
803
            Button {
804
                showingInlineTargetEditor = false
805
                draftTargetText = ""
806
            } label: {
807
                Image(systemName: "xmark.circle")
808
                    .foregroundColor(.secondary)
809
                    .font(.title3)
810
            }
811
            .buttonStyle(.plain)
812
        }
813
    }
814

            
815
    private func savedTargetControls(
816
        session: ChargeSessionSummary,
817
        savedBelowPrediction: Bool,
818
        predictedPercent: Double?
819
    ) -> some View {
820
        Group {
821
            if let targetPercent = session.targetBatteryPercent {
822
                Text("\(targetPercent.format(decimalDigits: 0))%")
823
                    .font(.subheadline.weight(.semibold))
824
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
825

            
826
                if savedBelowPrediction, let predictedPercent {
827
                    predictionWarningButton(predictedPercent: predictedPercent)
828
                }
829

            
830
                Button {
831
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
832
                } label: {
833
                    Image(systemName: "xmark.circle.fill")
834
                        .foregroundColor(.secondary)
835
                        .font(.callout)
836
                }
837
                .buttonStyle(.plain)
838
                .help("Remove alert")
839
            }
840

            
841
            Button {
842
                draftTargetText = session.targetBatteryPercent.map {
843
                    $0.format(decimalDigits: 0)
844
                } ?? "80"
845
                showingInlineTargetEditor = true
846
            } label: {
847
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
848
                    .font(.caption.weight(.semibold))
849
                    .frame(width: 30, height: 30)
850
                    .contentShape(Rectangle())
851
            }
852
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
853
            .buttonStyle(.plain)
854
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
855
        }
856
    }
857

            
858
    private func predictionWarningButton(predictedPercent: Double) -> some View {
859
        Button {} label: {
860
            Image(systemName: "exclamationmark.triangle.fill")
861
                .font(.callout.weight(.semibold))
862
                .foregroundColor(.orange)
863
        }
864
        .buttonStyle(.plain)
865
        .help("Battery is already predicted at \(predictedPercent.format(decimalDigits: 0))% - this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
866
    }
867

            
868
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
869
        HStack(spacing: 10) {
870
            if session.status == .active {
871
                Button("Pause") {
872
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
873
                }
874
                .monitoringActionStyle(tint: .orange)
875
            } else if session.status == .paused {
876
                Button("Resume") {
877
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
878
                }
879
                .monitoringActionStyle(tint: .blue)
880
            }
881

            
882
            Button("Terminate Session") {
883
                beginStopConfirmation(for: session)
884
            }
885
            .monitoringActionStyle(tint: .red)
886
        }
887
    }
888

            
889
    private func stopConfirmPanel(
890
        session: ChargeSessionSummary,
891
        displayedEnergyWh: Double,
892
        displayedChargeAh: Double
893
    ) -> some View {
894
        let canSave = hasSavableChargeData(
895
            session: session,
896
            displayedEnergyWh: displayedEnergyWh,
897
            displayedChargeAh: displayedChargeAh
898
        )
899
        let saveDisabledReason = saveDisabledReason(
900
            session: session,
901
            displayedEnergyWh: displayedEnergyWh,
902
            displayedChargeAh: displayedChargeAh
903
        )
904
        let isSaveEnabled = saveDisabledReason == nil
905

            
906
        return VStack(alignment: .leading, spacing: 12) {
907
            HStack {
908
                Text("Final Checkpoint")
909
                    .font(.subheadline.weight(.semibold))
910
                Text("optional")
911
                    .font(.caption2.weight(.semibold))
912
                    .foregroundColor(.secondary)
913
            }
914

            
915
            finalCheckpointPicker(session)
916

            
917
            if finalCheckpointMode == .custom {
918
                customFinalCheckpointRow
919
            }
920

            
921
            if let saveDisabledReason {
922
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
923
                    .font(.caption)
924
                    .foregroundColor(.red)
925
                    .fixedSize(horizontal: false, vertical: true)
926
            } else if let stopFailureMessage {
927
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
928
                    .font(.caption)
929
                    .foregroundColor(.red)
930
                    .fixedSize(horizontal: false, vertical: true)
931
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
932
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
933
                    .font(.caption)
934
                    .foregroundColor(.green)
935
                    .fixedSize(horizontal: false, vertical: true)
936
            }
937

            
938
            HStack(spacing: 8) {
939
                Button("Discard") {
940
                    discardSession(session)
941
                }
942
                .monitoringPanelActionStyle(tint: .secondary)
943

            
944
                Button {
945
                    stopSession(
946
                        session,
947
                        displayedEnergyWh: displayedEnergyWh,
948
                        displayedChargeAh: displayedChargeAh
949
                    )
950
                } label: {
951
                    Label("Save Session", systemImage: "checkmark.circle.fill")
952
                        .frame(maxWidth: .infinity)
953
                }
954
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
955
                .disabled(!isSaveEnabled)
956
                .help(saveDisabledReason ?? "Close and save this session")
957

            
958
                Button("Cancel") {
959
                    resetStopConfirmation()
960
                }
961
                .monitoringPanelActionStyle(tint: .secondary)
962
            }
963
        }
964
        .padding(14)
965
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
966
    }
967

            
968
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
969
        return HStack(spacing: 8) {
970
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
971
                Button {
972
                    finalCheckpointMode = mode
973
                    if mode == .custom {
974
                        prefillFinalCheckpointIfNeeded(for: session)
975
                    } else {
976
                        finalCheckpointText = ""
977
                    }
978
                } label: {
979
                    VStack(spacing: 5) {
980
                        Image(systemName: mode.icon)
981
                            .font(.title3)
982
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
983
                        Text(mode.label)
984
                            .font(.caption.weight(.semibold))
985
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
986
                    }
987
                    .frame(maxWidth: .infinity)
988
                    .padding(.vertical, 10)
989
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
990
                    .meterCard(
991
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
992
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
993
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
994
                        cornerRadius: 12
995
                    )
996
                }
997
                .buttonStyle(.plain)
998
            }
999
        }
1000
    }
1001

            
1002
    private var customFinalCheckpointRow: some View {
1003
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1004
            || parsedFinalCheckpoint == nil
1005

            
1006
        return HStack(spacing: 8) {
1007
            Button {
1008
                adjustFinalCheckpoint(by: -1)
1009
            } label: {
1010
                Image(systemName: "minus.circle").font(.title3)
1011
            }
1012
            .buttonStyle(.plain)
1013

            
1014
            TextField("-", text: $finalCheckpointText)
1015
                .keyboardType(.decimalPad)
1016
                .textFieldStyle(.roundedBorder)
1017
                .frame(width: 56)
1018
                .multilineTextAlignment(.center)
1019
                .overlay(
1020
                    RoundedRectangle(cornerRadius: 6)
1021
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1022
                )
1023

            
1024
            Text("%").foregroundColor(.secondary)
1025

            
1026
            Text("required")
1027
                .font(.caption2.weight(.semibold))
1028
                .foregroundColor(isInvalid ? .red : .secondary)
1029

            
1030
            Button {
1031
                adjustFinalCheckpoint(by: 1)
1032
            } label: {
1033
                Image(systemName: "plus.circle").font(.title3)
1034
            }
1035
            .buttonStyle(.plain)
1036

            
1037
            Spacer()
1038
        }
1039
    }
1040

            
1041
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1042
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1043
            if let meterName = session.meterName {
1044
                MeterInfoRowView(label: "Controlled On", value: meterName)
1045
            }
1046
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1047
                .font(.caption2)
1048
                .foregroundColor(.secondary)
1049
        }
1050
    }
1051

            
1052
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1053
        MeterInfoCardView(title: "Administration", tint: .red) {
1054
            Button(role: .destructive) {
1055
                pendingSessionDeletion = session
1056
            } label: {
1057
                Label("Delete Session", systemImage: "trash")
1058
                    .font(.subheadline.weight(.semibold))
1059
                    .frame(maxWidth: .infinity)
1060
                    .padding(.vertical, 10)
1061
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1062
            }
1063
            .buttonStyle(.plain)
1064
        }
1065
    }
1066

            
1067
    @ViewBuilder
1068
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1069
        if let window = detectedTrimWindow {
1070
            HStack(spacing: 12) {
1071
                Image(systemName: "scissors.circle.fill")
1072
                    .font(.title3)
1073
                    .foregroundColor(.blue)
1074

            
1075
                VStack(alignment: .leading, spacing: 2) {
1076
                    Text("Charging ended early")
1077
                        .font(.subheadline.weight(.semibold))
1078
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1079
                        .font(.caption)
1080
                        .foregroundColor(.secondary)
1081
                        .fixedSize(horizontal: false, vertical: true)
1082
                }
1083

            
1084
                Spacer(minLength: 0)
1085

            
1086
                VStack(spacing: 6) {
1087
                    Button("Trim Start") {
1088
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1089
                        trimBannerDismissedForSessionID = session.id
1090
                    }
1091
                    .font(.caption.weight(.semibold))
1092
                    .buttonStyle(.borderedProminent)
1093
                    .controlSize(.small)
1094
                    .tint(.blue)
1095

            
1096
                    Button("End & Finish") {
1097
                        requestStop(
1098
                            session,
1099
                            applyingTrimStart: session.trimStart ?? window.start,
1100
                            trimEnd: window.end,
1101
                            title: "Trim End & Finish",
1102
                            confirmTitle: "Finish",
1103
                            explanation: "The detected charging window will be saved before the session is closed."
1104
                        )
1105
                        trimBannerDismissedForSessionID = session.id
1106
                    }
1107
                    .font(.caption.weight(.semibold))
1108
                    .buttonStyle(.bordered)
1109
                    .controlSize(.small)
1110
                    .tint(.red)
1111
                }
1112
            }
1113
            .padding(14)
1114
            .background(
1115
                RoundedRectangle(cornerRadius: 14)
1116
                    .fill(Color.blue.opacity(0.10))
1117
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1118
            )
1119
            .transition(.opacity.combined(with: .move(edge: .top)))
1120
        }
1121
    }
1122

            
1123
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1124
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1125
    }
1126

            
1127
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1128
        ChargeSessionChartCardView(
1129
            session: session,
1130
            monitoringMeter: liveMonitoringMeter,
1131
            controlMode: chartControlMode(for: session),
1132
            onSetTrim: { start, end in
1133
                setSessionTrim(sessionID: session.id, start: start, end: end)
1134
            },
1135
            onStopWithTrim: { start, end in
1136
                requestStop(
1137
                    session,
1138
                    applyingTrimStart: start,
1139
                    trimEnd: end,
1140
                    title: "Trim End & Finish",
1141
                    confirmTitle: "Finish",
1142
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1143
                )
1144
            }
1145
        )
1146
    }
1147

            
1148
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1149
        if hasMonitoringControls {
1150
            return .activeMonitoring
1151
        }
1152

            
1153
        if session.status.isOpen == false {
1154
            return .closed
1155
        }
1156

            
1157
        return .none
1158
    }
1159

            
1160
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1161
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1162
        trimBannerDismissedForSessionID = sessionID
1163
    }
1164

            
1165
    private func requestStop(
1166
        _ session: ChargeSessionSummary,
1167
        applyingTrimStart trimStart: Date?,
1168
        trimEnd: Date?,
1169
        title: String,
1170
        confirmTitle: String,
1171
        explanation: String
1172
    ) {
1173
        pendingSessionStopRequest = ChargeSessionStopRequest(
1174
            sessionID: session.id,
1175
            title: title,
1176
            confirmTitle: confirmTitle,
1177
            explanation: explanation,
1178
            appliesTrim: trimStart != nil || trimEnd != nil,
1179
            trimStart: trimStart,
1180
            trimEnd: trimEnd
1181
        )
1182
    }
1183

            
1184
    private var parsedDraftTarget: Double? {
1185
        let normalized = draftTargetText
1186
            .trimmingCharacters(in: .whitespacesAndNewlines)
1187
            .replacingOccurrences(of: ",", with: ".")
1188
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1189
        return value
1190
    }
1191

            
1192
    private var parsedFinalCheckpoint: Double? {
1193
        let normalized = finalCheckpointText
1194
            .trimmingCharacters(in: .whitespacesAndNewlines)
1195
            .replacingOccurrences(of: ",", with: ".")
1196
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1197
        return value
1198
    }
1199

            
1200
    private var resolvedFinalCheckpoint: Double? {
1201
        switch finalCheckpointMode {
1202
        case .full:   return 100
1203
        case .skip:   return nil
1204
        case .custom: return parsedFinalCheckpoint
1205
        }
1206
    }
1207

            
1208
    private func adjustFinalCheckpoint(by delta: Double) {
1209
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1210
        let next = min(max(current + delta, 0), 100)
1211
        finalCheckpointText = next.format(decimalDigits: 0)
1212
    }
1213

            
1214
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1215
        guard let session else { return nil }
1216
        if let endBatteryPercent = session.endBatteryPercent {
1217
            return endBatteryPercent
1218
        }
1219
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1220
            return latestCheckpoint.batteryPercent
1221
        }
1222
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1223
    }
1224

            
1225
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1226
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1227
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1228
            return
1229
        }
1230
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1231
    }
1232

            
1233
    private func hasSavableChargeData(
1234
        session: ChargeSessionSummary,
1235
        displayedEnergyWh: Double,
1236
        displayedChargeAh: Double
1237
    ) -> Bool {
1238
        session.hasSavableChargeData
1239
            || displayedEnergyWh > 0
1240
            || displayedChargeAh > 0
1241
    }
1242

            
1243
    private func saveDisabledReason(
1244
        session: ChargeSessionSummary,
1245
        displayedEnergyWh: Double,
1246
        displayedChargeAh: Double
1247
    ) -> String? {
1248
        if finalCheckpointMode == .custom {
1249
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1250
            if trimmed.isEmpty {
1251
                return "Enter the final battery percentage or choose Skip."
1252
            }
1253
            if parsedFinalCheckpoint == nil {
1254
                return "Final battery percentage must be between 0 and 100."
1255
            }
1256
        }
1257

            
1258
        guard hasSavableChargeData(
1259
            session: session,
1260
            displayedEnergyWh: displayedEnergyWh,
1261
            displayedChargeAh: displayedChargeAh
1262
        ) else {
1263
            return "This session has no charging data to save. Discard it instead."
1264
        }
1265

            
1266
        return nil
1267
    }
1268

            
1269
    private func stopSession(
1270
        _ session: ChargeSessionSummary,
1271
        displayedEnergyWh: Double,
1272
        displayedChargeAh: Double
1273
    ) {
1274
        stopFailureMessage = nil
1275

            
1276
        if let saveDisabledReason = saveDisabledReason(
1277
            session: session,
1278
            displayedEnergyWh: displayedEnergyWh,
1279
            displayedChargeAh: displayedChargeAh
1280
        ) {
1281
            stopFailureMessage = saveDisabledReason
1282
            return
1283
        }
1284

            
1285
        let didSave = appData.stopChargeSession(
1286
            sessionID: session.id,
1287
            finalBatteryPercent: resolvedFinalCheckpoint,
1288
            from: liveMonitoringMeter
1289
        )
1290
        if didSave {
1291
            resetStopConfirmation()
1292
        } else {
1293
            stopFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment."
1294
        }
1295
    }
1296

            
1297
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1298
        finalCheckpointMode = .skip
1299
        finalCheckpointText = ""
1300
        stopFailureMessage = nil
1301
        showingStopConfirm = true
1302
    }
1303

            
1304
    private func discardSession(_ session: ChargeSessionSummary) {
1305
        _ = appData.deleteChargeSession(sessionID: session.id)
1306
        resetStopConfirmation()
1307
    }
1308

            
1309
    private func resetStopConfirmation() {
1310
        showingStopConfirm = false
1311
        finalCheckpointText = ""
1312
        finalCheckpointMode = .skip
1313
        stopFailureMessage = nil
1314
    }
1315

            
1316
    private func syncMonitoringRestore() {
1317
        guard let session,
1318
              session.status.isOpen,
1319
              let liveMonitoringMeter,
1320
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1321
            return
1322
        }
1323
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1324
    }
1325

            
1326
    private func runTrimDetection() {
1327
        guard hasMonitoringControls,
1328
              let session,
1329
              session.isTrimmed == false,
1330
              !session.aggregatedSamples.isEmpty else {
1331
            detectedTrimWindow = nil
1332
            return
1333
        }
1334

            
1335
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1336
        detectedTrimWindow = ChargingWindowDetector.detect(
1337
            samples: session.aggregatedSamples,
1338
            sessionStart: session.startedAt,
1339
            sessionEnd: sessionEnd
1340
        )
1341
    }
1342

            
1343
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1344
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1345
        guard session.isTrimmed == false else { return storedEnergyWh }
1346
        guard session.status.isOpen else { return storedEnergyWh }
1347
        guard let liveMonitoringMeter else { return storedEnergyWh }
1348
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1349
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1350
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1351
        }
1352
        return storedEnergyWh
1353
    }
1354

            
1355
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1356
        let storedChargeAh = session.measuredChargeAh
1357
        guard session.isTrimmed == false else { return storedChargeAh }
1358
        guard session.status.isOpen else { return storedChargeAh }
1359
        guard let liveMonitoringMeter else { return storedChargeAh }
1360
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
1361
        if let baselineChargeAh = session.meterChargeBaselineAh {
1362
            return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0))
1363
        }
1364
        return storedChargeAh
1365
    }
1366

            
1367
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1368
        let storedDuration = max(session.effectiveDuration, 0)
1369
        guard session.isTrimmed == false else { return storedDuration }
1370
        guard session.status.isOpen else { return storedDuration }
1371
        guard let liveMonitoringMeter else { return storedDuration }
1372
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1373
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1374
    }
1375

            
1376
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1377
        let displayedDuration = displayedSessionDuration(for: session)
1378
        let formatter = DateComponentsFormatter()
1379
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1380
        formatter.unitsStyle = .abbreviated
1381
        formatter.zeroFormattingBehavior = .dropAll
1382
        return formatter.string(from: displayedDuration) ?? "0m"
1383
    }
1384

            
1385
    private func formatDuration(_ duration: TimeInterval) -> String {
1386
        let totalSeconds = Int(duration.rounded(.down))
1387
        let hours = totalSeconds / 3600
1388
        let minutes = (totalSeconds % 3600) / 60
1389
        let seconds = totalSeconds % 60
1390
        if hours > 0 {
1391
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1392
        }
1393
        return String(format: "%02d:%02d", minutes, seconds)
1394
    }
1395

            
1396
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1397
        if session.autoStopEnabled == false {
1398
            return "Manual"
1399
        }
1400

            
1401
        if let sessionWarning = sessionWarning(for: session),
1402
           sessionWarning.contains("idle-current") {
1403
            return "Blocked by charger setup"
1404
        }
1405

            
1406
        if session.stopThresholdAmps > 0 {
1407
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1408
        }
1409

            
1410
        return "Learning"
1411
    }
1412

            
1413
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1414
        if session.autoStopEnabled == false {
1415
            return "Manual"
1416
        }
1417
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1418
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1419
        }
1420
        if session.stopThresholdAmps > 0 {
1421
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1422
        }
1423
        return "Learning"
1424
    }
1425

            
1426
    private func shouldShowChargingTransport(
1427
        for session: ChargeSessionSummary,
1428
        chargedDevice: ChargedDeviceSummary
1429
    ) -> Bool {
1430
        chargedDevice.supportedChargingModes.count > 1
1431
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1432
    }
1433

            
1434
    private func shouldShowChargingState(
1435
        for session: ChargeSessionSummary,
1436
        chargedDevice: ChargedDeviceSummary
1437
    ) -> Bool {
1438
        chargedDevice.supportedChargingStateModes.count > 1
1439
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1440
    }
1441

            
1442
    private func batteryColor(for percent: Double) -> Color {
1443
        if percent >= 75 { return .green }
1444
        if percent >= 35 { return .orange }
1445
        return .red
1446
    }
1447

            
1448
    private func etaText(
1449
        rateWhPerSec: Double?,
1450
        remainingWh: Double,
1451
        isRelevant: Bool
1452
    ) -> String? {
1453
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1454
        let seconds = remainingWh / rateWhPerSec
1455
        return seconds > 120 ? formatETA(seconds) : nil
1456
    }
1457

            
1458
    private func etaToTargetText(
1459
        session: ChargeSessionSummary,
1460
        prediction: BatteryLevelPrediction,
1461
        displayedEnergyWh: Double,
1462
        rateWhPerSec: Double?
1463
    ) -> String? {
1464
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1465
            return nil
1466
        }
1467
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1468
        return etaText(
1469
            rateWhPerSec: rateWhPerSec,
1470
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1471
            isRelevant: true
1472
        )
1473
    }
1474

            
1475
    private func formatETA(_ seconds: TimeInterval) -> String {
1476
        let totalMinutes = Int(seconds / 60)
1477
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1478
        let hours = totalMinutes / 60
1479
        let minutes = totalMinutes % 60
1480
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1481
    }
1482

            
1483
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1484
        switch session.status {
1485
        case .active:
1486
            return .red
1487
        case .paused:
1488
            return .orange
1489
        case .completed:
1490
            return .green
1491
        case .abandoned:
1492
            return .secondary
1493
        }
1494
    }
1495

            
1496
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1497
        nil
Bogdan Timofte authored a month ago
1498
    }
1499

            
1500
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1501
        guard session.chargingTransportMode == .wireless else {
1502
            return nil
1503
        }
1504

            
1505
        var components: [String] = []
1506
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1507
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1508
        }
1509
        if session.usesEstimatedWirelessEfficiency {
1510
            components.append("Estimated from wired baseline and checkpoints")
1511
        }
1512
        if session.shouldWarnAboutLowWirelessEfficiency {
1513
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1514
        }
1515

            
1516
        return components.isEmpty ? nil : components.joined(separator: " - ")
1517
    }
1518

            
1519
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1520
        switch session.status {
1521
        case .active:
1522
            return .green
1523
        case .paused:
1524
            return .orange
1525
        case .completed:
1526
            return .teal
1527
        case .abandoned:
1528
            return .secondary
1529
        }
1530
    }
1531
}
1532

            
1533
enum ChargeSessionChartControlMode {
1534
    case none
1535
    case activeMonitoring
1536
    case closed
1537
}
1538

            
1539
struct ChargeSessionChartCardView: View {
1540
    let session: ChargeSessionSummary
1541
    let monitoringMeter: Meter?
1542
    let controlMode: ChargeSessionChartControlMode
1543
    let onSetTrim: (Date?, Date?) -> Void
1544
    let onStopWithTrim: (Date?, Date?) -> Void
1545

            
1546
    @StateObject private var storedMeasurements = Measurements()
1547

            
1548
    private var chartMeasurements: Measurements {
1549
        if let monitoringMeter,
1550
           session.status.isOpen,
1551
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1552
            return monitoringMeter.chargeRecordMeasurements
1553
        }
1554
        return storedMeasurements
1555
    }
1556

            
1557
    private var fullTimeRange: ClosedRange<Date> {
1558
        let start = session.startedAt
1559
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1560
        return start...end
1561
    }
1562

            
1563
    private var fixedTimeRange: ClosedRange<Date>? {
1564
        if monitoringMeter != nil && session.status.isOpen {
1565
            return nil
1566
        }
1567
        return session.effectiveTimeRange
1568
    }
1569

            
1570
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1571
        guard monitoringMeter != nil && session.status.isOpen else {
1572
            return (nil, nil)
1573
        }
1574
        return (session.trimStart, session.trimEnd)
1575
    }
1576

            
1577
    private var showsRangeSelector: Bool {
1578
        controlMode != .none && !session.aggregatedSamples.isEmpty
1579
    }
1580

            
1581
    var body: some View {
1582
        VStack(alignment: .leading, spacing: 12) {
1583
            HStack(spacing: 8) {
1584
                Image(systemName: "chart.xyaxis.line")
1585
                    .foregroundColor(.blue)
1586
                Text("Session Chart")
1587
                    .font(.headline)
1588
                ContextInfoButton(
1589
                    title: "Session Chart",
1590
                    message: chartInfoMessage
1591
                )
1592
                Spacer(minLength: 0)
1593
            }
1594

            
1595
            MeasurementChartView(
1596
                timeRange: fixedTimeRange,
1597
                timeRangeLowerBound: liveTrimBounds.lower,
1598
                timeRangeUpperBound: liveTrimBounds.upper,
1599
                showsRangeSelector: showsRangeSelector,
1600
                rebasesEnergyToVisibleRangeStart: true,
1601
                extendsTimelineToPresent: false,
1602
                showsTemperatureSeries: false,
1603
                rangeSelectorConfiguration: rangeSelectorConfiguration
1604
            )
1605
            .environmentObject(chartMeasurements)
1606
            .frame(maxWidth: .infinity, alignment: .topLeading)
1607
        }
1608
        .padding(18)
1609
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1610
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1611
        .onChange(of: session.id) { _ in
1612
            restoreStoredMeasurementsIfNeeded()
1613
        }
1614
        .onChange(of: session.aggregatedSamples.count) { _ in
1615
            restoreStoredMeasurementsIfNeeded()
1616
        }
1617
    }
1618

            
1619
    private var chartInfoMessage: String {
1620
        if monitoringMeter != nil && session.status.isOpen {
1621
            return "This chart combines the persisted session curve with current live data from this meter."
1622
        }
1623

            
1624
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1625
    }
1626

            
1627
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1628
        switch controlMode {
1629
        case .none:
1630
            return nil
1631
        case .activeMonitoring:
1632
            return MeasurementChartRangeSelectorConfiguration(
1633
                keepAction: MeasurementChartSelectionAction(
1634
                    title: "Trim Start",
1635
                    shortTitle: "Start",
1636
                    systemName: "arrow.right.to.line",
1637
                    tone: .destructive,
1638
                    handler: applyActiveStartTrim
1639
                ),
1640
                removeAction: MeasurementChartSelectionAction(
1641
                    title: "Trim End & Finish",
1642
                    shortTitle: "End",
1643
                    systemName: "arrow.left.to.line",
1644
                    tone: .destructiveProminent,
1645
                    handler: requestActiveEndTrim
1646
                ),
1647
                resetAction: MeasurementChartResetAction(
1648
                    title: "Reset Trim",
1649
                    shortTitle: "Reset",
1650
                    systemName: "arrow.counterclockwise",
1651
                    tone: .reversible,
1652
                    confirmationTitle: "Reset session trim?",
1653
                    confirmationButtonTitle: "Reset trim",
1654
                    handler: {
1655
                        onSetTrim(nil, nil)
1656
                    }
1657
                )
1658
            )
1659
        case .closed:
1660
            return MeasurementChartRangeSelectorConfiguration(
1661
                keepAction: MeasurementChartSelectionAction(
1662
                    title: "Trim Window",
1663
                    shortTitle: "Trim",
1664
                    systemName: "scissors",
1665
                    tone: .destructive,
1666
                    handler: applyClosedTrim
1667
                ),
1668
                removeAction: nil,
1669
                resetAction: MeasurementChartResetAction(
1670
                    title: "Reset Trim",
1671
                    shortTitle: "Reset",
1672
                    systemName: "arrow.counterclockwise",
1673
                    tone: .reversible,
1674
                    confirmationTitle: "Reset session trim?",
1675
                    confirmationButtonTitle: "Reset trim",
1676
                    handler: {
1677
                        onSetTrim(nil, nil)
1678
                    }
1679
                )
1680
            )
1681
        }
1682
    }
1683

            
1684
    private func restoreStoredMeasurementsIfNeeded() {
1685
        guard monitoringMeter == nil || session.status.isOpen == false else {
1686
            return
1687
        }
1688
        storedMeasurements.resetSeries()
1689
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1690
            from: session,
1691
            replacingLiveBufferIfNeeded: true
1692
        )
1693
    }
1694

            
1695
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1696
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1697
    }
1698

            
1699
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1700
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1701
        let end = normalizedEnd(range.upperBound)
1702
        onStopWithTrim(start, end)
1703
    }
1704

            
1705
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1706
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1707
    }
1708

            
1709
    private func normalizedStart(_ date: Date) -> Date? {
1710
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1711
    }
1712

            
1713
    private func normalizedEnd(_ date: Date) -> Date? {
1714
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1715
    }
1716
}
1717

            
1718
private struct ChargeSessionStopRequest: Identifiable {
1719
    let sessionID: UUID
1720
    let title: String
1721
    let confirmTitle: String
1722
    let explanation: String
1723
    let appliesTrim: Bool
1724
    let trimStart: Date?
1725
    let trimEnd: Date?
1726

            
1727
    var id: String {
1728
        [
1729
            sessionID.uuidString,
1730
            title,
1731
            trimStart?.timeIntervalSince1970.description ?? "nil",
1732
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1733
        ].joined(separator: "-")
1734
    }
1735
}
1736

            
1737
private extension View {
1738
    func monitoringActionStyle(tint: Color) -> some View {
1739
        frame(maxWidth: .infinity)
1740
            .padding(.vertical, 10)
1741
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1742
            .buttonStyle(.plain)
1743
    }
1744

            
1745
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1746
        frame(maxWidth: .infinity)
1747
            .padding(.vertical, 9)
1748
            .meterCard(
1749
                tint: tint,
1750
                fillOpacity: isProminent ? 0.22 : 0.10,
1751
                strokeOpacity: isProminent ? 0.32 : 0.14,
1752
                cornerRadius: 14
1753
            )
1754
            .buttonStyle(.plain)
1755
    }
1756
}