Newer Older
1796 lines | 70.546kb
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
                    batteryCard(session, chargedDevice: chargedDevice)
201

            
202
                    if shouldShowSessionChart(session) {
203
                        chartCard(session)
204
                    }
205

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

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

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

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

            
258
                Spacer()
259

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

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

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

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

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

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

            
304
            if session.requiresCompletionConfirmation && !showingStopConfirm {
305
                completionConfirmationCard(session)
306
            }
307

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

            
321
            targetSectionView(
322
                session: session,
323
                predictedPercent: batteryPrediction?.predictedPercent
324
            )
325

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

            
339
    private func overviewCard(
340
        _ session: ChargeSessionSummary,
341
        chargedDevice: ChargedDeviceSummary
342
    ) -> some View {
Bogdan Timofte authored a month ago
343
        MeterInfoCardView(
344
            title: session.status.isOpen ? "Open Session" : "Overview",
345
            tint: statusTint(for: session),
346
            isCollapsible: true
347
        ) {
348
            VStack(alignment: .leading, spacing: 10) {
349
                MeterInfoRowView(label: "Device", value: chargedDevice.name)
350

            
351
                Divider()
352

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

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

            
365
                Divider()
366

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

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

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

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

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

            
406
                    Divider()
407

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
472
        return MeterInfoCardView(title: "Battery", tint: .orange, isCollapsible: true) {
Bogdan Timofte authored a month ago
473
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
474

            
Bogdan Timofte authored a month ago
475
                // Energy
476
                HStack(alignment: .top, spacing: 12) {
477
                    overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
478
                    if let capacityEstimateWh = session.capacityEstimateWh {
479
                        overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
480
                    }
Bogdan Timofte authored a month ago
481
                }
Bogdan Timofte authored a month ago
482
                if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
483
                    MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
484
                }
485
                if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
486
                   abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
487
                    MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
488
                }
489
                if let chargerID = session.chargerID,
490
                   let charger = appData.chargedDeviceSummary(id: chargerID) {
491
                    MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
492
                }
493
                if let wirelessSessionHint = wirelessSessionHint(for: session) {
494
                    Text(wirelessSessionHint)
495
                        .font(.caption2)
496
                        .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
497
                }
498
                if let sessionWarning = sessionWarning(for: session) {
499
                    Label(sessionWarning, systemImage: "exclamationmark.triangle")
500
                        .font(.caption2)
501
                        .foregroundColor(.orange)
502
                }
503

            
504
                // Battery percentages
505
                if session.startBatteryPercent != nil || session.endBatteryPercent != nil {
506
                    Divider()
507
                    HStack(alignment: .top, spacing: 12) {
508
                        if let v = session.startBatteryPercent {
509
                            overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
510
                        }
511
                        if let v = session.endBatteryPercent {
512
                            overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
513
                        }
514
                    }
515
                    if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
516
                        HStack(alignment: .top, spacing: 12) {
517
                            if let v = session.batteryDeltaPercent {
518
                                overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
519
                            }
520
                            if let v = session.targetBatteryPercent {
521
                                overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
522
                            }
523
                        }
524
                    }
525
                    if let batteryPrediction {
526
                        HStack(alignment: .top, spacing: 12) {
527
                            overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
528
                        }
529
                        Text(
530
                            "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
531
                        )
532
                        .font(.caption2)
533
                        .foregroundColor(.secondary)
534
                    }
535
                }
536

            
537
                // Checkpoints
538
                Divider()
539
                BatteryCheckpointSectionView(
540
                    sessionID: session.id,
541
                    checkpoints: session.checkpoints,
542
                    message: session.status.isOpen
543
                        ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
544
                        : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
545
                    canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
546
                    canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
547
                    requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
548
                    effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
549
                    onDelete: { checkpoint in
550
                        pendingCheckpointDeletion = checkpoint
551
                    }
552
                )
553
            }
Bogdan Timofte authored a month ago
554
        }
555
    }
556

            
557
    private func batteryGaugeSection(
558
        prediction: BatteryLevelPrediction,
559
        session: ChargeSessionSummary,
560
        displayedEnergyWh: Double
561
    ) -> some View {
562
        let percent = prediction.predictedPercent
563
        let color = batteryColor(for: percent)
564
        let duration = displayedSessionDuration(for: session)
565
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
566
            ? displayedEnergyWh / duration
567
            : nil
568
        let etaToFull = etaText(
569
            rateWhPerSec: rateWhPerSec,
570
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
571
            isRelevant: percent < 98
572
        )
573
        let etaToTarget = etaToTargetText(
574
            session: session,
575
            prediction: prediction,
576
            displayedEnergyWh: displayedEnergyWh,
577
            rateWhPerSec: rateWhPerSec
578
        )
579

            
580
        return VStack(spacing: 10) {
581
            HStack(alignment: .lastTextBaseline, spacing: 8) {
582
                HStack(alignment: .lastTextBaseline, spacing: 3) {
583
                    Text("\(Int(percent.rounded()))")
584
                        .font(.system(size: 52, weight: .bold, design: .rounded))
585
                        .foregroundColor(color)
586
                        .monospacedDigit()
587
                    Text("%")
588
                        .font(.title2.weight(.semibold))
589
                        .foregroundColor(color.opacity(0.8))
590
                }
591

            
592
                Spacer()
593

            
594
                VStack(alignment: .trailing, spacing: 2) {
595
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
596
                        .font(.callout.weight(.bold))
597
                        .foregroundColor(.orange)
598
                        .monospacedDigit()
599
                    Text("est. capacity")
600
                        .font(.caption2)
601
                        .foregroundColor(.secondary)
602
                }
603
            }
604

            
605
            batteryProgressBar(
606
                percent: percent,
607
                startPercent: session.startBatteryPercent,
608
                targetPercent: session.targetBatteryPercent
609
            )
610

            
611
            HStack(spacing: 14) {
612
                if let etaToFull {
613
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
614
                }
615

            
616
                if let etaToTarget, let target = session.targetBatteryPercent {
617
                    etaPill(
618
                        icon: "bell.badge.fill",
619
                        tint: .indigo,
620
                        value: etaToTarget,
621
                        label: "to \(Int(target.rounded()))%"
622
                    )
623
                }
624

            
625
                Spacer()
626

            
627
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
628
                    .font(.caption2)
629
                    .foregroundColor(.secondary)
630
                    .multilineTextAlignment(.trailing)
631
            }
632
        }
633
        .padding(14)
634
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
635
    }
636

            
637
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
638
        VStack(alignment: .leading, spacing: 1) {
639
            HStack(spacing: 4) {
640
                Image(systemName: icon)
641
                    .font(.caption)
642
                    .foregroundColor(tint)
643
                Text(value)
644
                    .font(.caption.weight(.bold))
645
            }
646
            Text(label)
647
                .font(.caption2)
648
                .foregroundColor(.secondary)
649
        }
650
    }
651

            
652
    private func batteryProgressBar(
653
        percent: Double,
654
        startPercent: Double?,
655
        targetPercent: Double?
656
    ) -> some View {
657
        let color = batteryColor(for: percent)
658
        return GeometryReader { geo in
659
            let width = geo.size.width
660
            ZStack(alignment: .leading) {
661
                Capsule()
662
                    .fill(Color.primary.opacity(0.10))
663
                Rectangle()
664
                    .fill(
665
                        LinearGradient(
666
                            colors: [color.opacity(0.6), color],
667
                            startPoint: .leading,
668
                            endPoint: .trailing
669
                        )
670
                    )
671
                    .frame(width: max(width * CGFloat(percent / 100), 4))
672
                    .animation(.easeInOut(duration: 0.4), value: percent)
673
                if let start = startPercent, start > 2, start < 98 {
674
                    Rectangle()
675
                        .fill(Color.white.opacity(0.55))
676
                        .frame(width: 2, height: 20)
677
                        .offset(x: width * CGFloat(start / 100) - 1)
678
                }
679
                if let target = targetPercent {
680
                    Rectangle()
681
                        .fill(Color.indigo.opacity(0.9))
682
                        .frame(width: 2.5, height: 20)
683
                        .offset(x: width * CGFloat(target / 100) - 1.25)
684
                }
685
            }
686
            .clipShape(Capsule())
687
        }
688
        .frame(height: 20)
689
    }
690

            
691
    private func sessionMetricsGrid(
692
        session: ChargeSessionSummary,
693
        chargedDevice: ChargedDeviceSummary,
694
        displayedEnergyWh: Double,
695
        hasPrediction: Bool
696
    ) -> some View {
697
        let capacityFallback: Double? = hasPrediction ? nil : (
698
            session.capacityEstimateWh
699
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
700
                ?? chargedDevice.estimatedBatteryCapacityWh
701
        )
702
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
703

            
704
        return LazyVGrid(columns: columns, spacing: 8) {
705
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
706
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
707

            
708
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
709
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
710
            }
711
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
712
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
713
            }
714

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

            
717
            if let capacityFallback {
718
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
719
            }
720
        }
721
    }
722

            
723
    private func metricCell(label: String, value: String, tint: Color) -> some View {
724
        VStack(alignment: .leading, spacing: 3) {
725
            Text(label)
726
                .font(.caption2)
727
                .foregroundColor(.secondary)
728
            Text(value)
729
                .font(.subheadline.weight(.semibold))
730
                .lineLimit(1)
731
                .minimumScaleFactor(0.7)
732
                .monospacedDigit()
733
        }
734
        .frame(maxWidth: .infinity, alignment: .leading)
735
        .padding(.horizontal, 12)
736
        .padding(.vertical, 10)
737
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
738
    }
739

            
740
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
741
        VStack(alignment: .leading, spacing: 10) {
742
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
743
                .font(.subheadline.weight(.semibold))
744

            
745
            if let contradictionPercent = session.completionContradictionPercent {
746
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
747
                    .font(.caption)
748
                    .foregroundColor(.secondary)
749
            } else {
750
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
751
                    .font(.caption)
752
                    .foregroundColor(.secondary)
753
            }
754

            
755
            HStack(spacing: 10) {
756
                Button("Finish") {
757
                    beginStopConfirmation(for: session)
758
                }
759
                .frame(maxWidth: .infinity)
760
                .padding(.vertical, 9)
761
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
762
                .buttonStyle(.plain)
763

            
764
                Button("Keep Monitoring") {
765
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
766
                }
767
                .frame(maxWidth: .infinity)
768
                .padding(.vertical, 9)
769
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
770
                .buttonStyle(.plain)
771
            }
772
        }
773
        .padding(14)
774
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
775
    }
776

            
777
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
778
        let draftBelowPrediction: Bool = {
779
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
780
            return draft <= predictedPercent
781
        }()
782
        let savedBelowPrediction: Bool = {
783
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
784
            return saved <= predictedPercent
785
        }()
786

            
787
        return HStack(alignment: .center, spacing: 8) {
788
            Image(systemName: "bell.badge")
789
                .foregroundColor(.indigo)
790
                .font(.subheadline)
791

            
792
            Text("Notify at")
793
                .font(.subheadline.weight(.semibold))
794

            
795
            Spacer(minLength: 8)
796

            
797
            if showingInlineTargetEditor {
798
                targetEditorControls(
799
                    session: session,
800
                    draftBelowPrediction: draftBelowPrediction,
801
                    predictedPercent: predictedPercent
802
                )
803
            } else {
804
                savedTargetControls(
805
                    session: session,
806
                    savedBelowPrediction: savedBelowPrediction,
807
                    predictedPercent: predictedPercent
808
                )
809
            }
810
        }
811
    }
812

            
813
    private func targetEditorControls(
814
        session: ChargeSessionSummary,
815
        draftBelowPrediction: Bool,
816
        predictedPercent: Double?
817
    ) -> some View {
818
        Group {
819
            Button {
820
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
821
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
822
            } label: {
823
                Image(systemName: "minus.circle")
824
                    .font(.title3)
825
            }
826
            .buttonStyle(.plain)
827

            
828
            TextField("-", text: $draftTargetText)
829
                .keyboardType(.decimalPad)
830
                .textFieldStyle(.roundedBorder)
831
                .frame(width: 48)
832
                .multilineTextAlignment(.center)
833
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
834

            
835
            Text("%")
836
                .font(.subheadline)
837
                .foregroundColor(.secondary)
838

            
839
            if draftBelowPrediction, let predictedPercent {
840
                predictionWarningButton(predictedPercent: predictedPercent)
841
            }
842

            
843
            Button {
844
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
845
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
846
            } label: {
847
                Image(systemName: "plus.circle")
848
                    .font(.title3)
849
            }
850
            .buttonStyle(.plain)
851

            
852
            Button {
853
                if let value = parsedDraftTarget {
854
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
855
                }
856
                showingInlineTargetEditor = false
857
            } label: {
858
                Image(systemName: "checkmark.circle.fill")
859
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
860
                    .font(.title3)
861
            }
862
            .buttonStyle(.plain)
863
            .disabled(parsedDraftTarget == nil)
864

            
865
            Button {
866
                showingInlineTargetEditor = false
867
                draftTargetText = ""
868
            } label: {
869
                Image(systemName: "xmark.circle")
870
                    .foregroundColor(.secondary)
871
                    .font(.title3)
872
            }
873
            .buttonStyle(.plain)
874
        }
875
    }
876

            
877
    private func savedTargetControls(
878
        session: ChargeSessionSummary,
879
        savedBelowPrediction: Bool,
880
        predictedPercent: Double?
881
    ) -> some View {
882
        Group {
883
            if let targetPercent = session.targetBatteryPercent {
884
                Text("\(targetPercent.format(decimalDigits: 0))%")
885
                    .font(.subheadline.weight(.semibold))
886
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
887

            
888
                if savedBelowPrediction, let predictedPercent {
889
                    predictionWarningButton(predictedPercent: predictedPercent)
890
                }
891

            
892
                Button {
893
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
894
                } label: {
895
                    Image(systemName: "xmark.circle.fill")
896
                        .foregroundColor(.secondary)
897
                        .font(.callout)
898
                }
899
                .buttonStyle(.plain)
900
                .help("Remove alert")
901
            }
902

            
903
            Button {
904
                draftTargetText = session.targetBatteryPercent.map {
905
                    $0.format(decimalDigits: 0)
906
                } ?? "80"
907
                showingInlineTargetEditor = true
908
            } label: {
909
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
910
                    .font(.caption.weight(.semibold))
911
                    .frame(width: 30, height: 30)
912
                    .contentShape(Rectangle())
913
            }
914
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
915
            .buttonStyle(.plain)
916
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
917
        }
918
    }
919

            
920
    private func predictionWarningButton(predictedPercent: Double) -> some View {
921
        Button {} label: {
922
            Image(systemName: "exclamationmark.triangle.fill")
923
                .font(.callout.weight(.semibold))
924
                .foregroundColor(.orange)
925
        }
926
        .buttonStyle(.plain)
927
        .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.")
928
    }
929

            
930
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
931
        HStack(spacing: 10) {
932
            if session.status == .active {
933
                Button("Pause") {
934
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
935
                }
936
                .monitoringActionStyle(tint: .orange)
937
            } else if session.status == .paused {
938
                Button("Resume") {
939
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
940
                }
941
                .monitoringActionStyle(tint: .blue)
942
            }
943

            
944
            Button("Terminate Session") {
945
                beginStopConfirmation(for: session)
946
            }
947
            .monitoringActionStyle(tint: .red)
948
        }
949
    }
950

            
951
    private func stopConfirmPanel(
952
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
953
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
954
    ) -> some View {
955
        let canSave = hasSavableChargeData(
956
            session: session,
Bogdan Timofte authored a month ago
957
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
958
        )
959
        let saveDisabledReason = saveDisabledReason(
960
            session: session,
Bogdan Timofte authored a month ago
961
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
962
        )
963
        let isSaveEnabled = saveDisabledReason == nil
964

            
965
        return VStack(alignment: .leading, spacing: 12) {
966
            HStack {
967
                Text("Final Checkpoint")
968
                    .font(.subheadline.weight(.semibold))
969
                Text("optional")
970
                    .font(.caption2.weight(.semibold))
971
                    .foregroundColor(.secondary)
972
            }
973

            
974
            finalCheckpointPicker(session)
975

            
976
            if finalCheckpointMode == .custom {
977
                customFinalCheckpointRow
978
            }
979

            
980
            if let saveDisabledReason {
981
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
982
                    .font(.caption)
983
                    .foregroundColor(.red)
984
                    .fixedSize(horizontal: false, vertical: true)
985
            } else if let stopFailureMessage {
986
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
987
                    .font(.caption)
988
                    .foregroundColor(.red)
989
                    .fixedSize(horizontal: false, vertical: true)
990
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
991
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
992
                    .font(.caption)
993
                    .foregroundColor(.green)
994
                    .fixedSize(horizontal: false, vertical: true)
995
            }
996

            
997
            HStack(spacing: 8) {
998
                Button("Discard") {
999
                    discardSession(session)
1000
                }
1001
                .monitoringPanelActionStyle(tint: .secondary)
1002

            
1003
                Button {
1004
                    stopSession(
1005
                        session,
Bogdan Timofte authored a month ago
1006
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1007
                    )
1008
                } label: {
1009
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1010
                        .frame(maxWidth: .infinity)
1011
                }
1012
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1013
                .disabled(!isSaveEnabled)
1014
                .help(saveDisabledReason ?? "Close and save this session")
1015

            
1016
                Button("Cancel") {
1017
                    resetStopConfirmation()
1018
                }
1019
                .monitoringPanelActionStyle(tint: .secondary)
1020
            }
1021
        }
1022
        .padding(14)
1023
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1024
    }
1025

            
1026
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1027
        return HStack(spacing: 8) {
1028
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1029
                Button {
1030
                    finalCheckpointMode = mode
1031
                    if mode == .custom {
1032
                        prefillFinalCheckpointIfNeeded(for: session)
1033
                    } else {
1034
                        finalCheckpointText = ""
1035
                    }
1036
                } label: {
1037
                    VStack(spacing: 5) {
1038
                        Image(systemName: mode.icon)
1039
                            .font(.title3)
1040
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1041
                        Text(mode.label)
1042
                            .font(.caption.weight(.semibold))
1043
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1044
                    }
1045
                    .frame(maxWidth: .infinity)
1046
                    .padding(.vertical, 10)
1047
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1048
                    .meterCard(
1049
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1050
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1051
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1052
                        cornerRadius: 12
1053
                    )
1054
                }
1055
                .buttonStyle(.plain)
1056
            }
1057
        }
1058
    }
1059

            
1060
    private var customFinalCheckpointRow: some View {
1061
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1062
            || parsedFinalCheckpoint == nil
1063

            
1064
        return HStack(spacing: 8) {
1065
            Button {
1066
                adjustFinalCheckpoint(by: -1)
1067
            } label: {
1068
                Image(systemName: "minus.circle").font(.title3)
1069
            }
1070
            .buttonStyle(.plain)
1071

            
1072
            TextField("-", text: $finalCheckpointText)
1073
                .keyboardType(.decimalPad)
1074
                .textFieldStyle(.roundedBorder)
1075
                .frame(width: 56)
1076
                .multilineTextAlignment(.center)
1077
                .overlay(
1078
                    RoundedRectangle(cornerRadius: 6)
1079
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1080
                )
1081

            
1082
            Text("%").foregroundColor(.secondary)
1083

            
1084
            Text("required")
1085
                .font(.caption2.weight(.semibold))
1086
                .foregroundColor(isInvalid ? .red : .secondary)
1087

            
1088
            Button {
1089
                adjustFinalCheckpoint(by: 1)
1090
            } label: {
1091
                Image(systemName: "plus.circle").font(.title3)
1092
            }
1093
            .buttonStyle(.plain)
1094

            
1095
            Spacer()
1096
        }
1097
    }
1098

            
1099
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1100
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1101
            if let meterName = session.meterName {
1102
                MeterInfoRowView(label: "Controlled On", value: meterName)
1103
            }
1104
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1105
                .font(.caption2)
1106
                .foregroundColor(.secondary)
1107
        }
1108
    }
1109

            
1110
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1111
        MeterInfoCardView(title: "Administration", tint: .red) {
1112
            Button(role: .destructive) {
1113
                pendingSessionDeletion = session
1114
            } label: {
1115
                Label("Delete Session", systemImage: "trash")
1116
                    .font(.subheadline.weight(.semibold))
1117
                    .frame(maxWidth: .infinity)
1118
                    .padding(.vertical, 10)
1119
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1120
            }
1121
            .buttonStyle(.plain)
1122
        }
1123
    }
1124

            
1125
    @ViewBuilder
1126
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1127
        if let window = detectedTrimWindow {
1128
            HStack(spacing: 12) {
1129
                Image(systemName: "scissors.circle.fill")
1130
                    .font(.title3)
1131
                    .foregroundColor(.blue)
1132

            
1133
                VStack(alignment: .leading, spacing: 2) {
1134
                    Text("Charging ended early")
1135
                        .font(.subheadline.weight(.semibold))
1136
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1137
                        .font(.caption)
1138
                        .foregroundColor(.secondary)
1139
                        .fixedSize(horizontal: false, vertical: true)
1140
                }
1141

            
1142
                Spacer(minLength: 0)
1143

            
1144
                VStack(spacing: 6) {
1145
                    Button("Trim Start") {
1146
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1147
                        trimBannerDismissedForSessionID = session.id
1148
                    }
1149
                    .font(.caption.weight(.semibold))
1150
                    .buttonStyle(.borderedProminent)
1151
                    .controlSize(.small)
1152
                    .tint(.blue)
1153

            
1154
                    Button("End & Finish") {
1155
                        requestStop(
1156
                            session,
1157
                            applyingTrimStart: session.trimStart ?? window.start,
1158
                            trimEnd: window.end,
1159
                            title: "Trim End & Finish",
1160
                            confirmTitle: "Finish",
1161
                            explanation: "The detected charging window will be saved before the session is closed."
1162
                        )
1163
                        trimBannerDismissedForSessionID = session.id
1164
                    }
1165
                    .font(.caption.weight(.semibold))
1166
                    .buttonStyle(.bordered)
1167
                    .controlSize(.small)
1168
                    .tint(.red)
1169
                }
1170
            }
1171
            .padding(14)
1172
            .background(
1173
                RoundedRectangle(cornerRadius: 14)
1174
                    .fill(Color.blue.opacity(0.10))
1175
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1176
            )
1177
            .transition(.opacity.combined(with: .move(edge: .top)))
1178
        }
1179
    }
1180

            
1181
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1182
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1183
    }
1184

            
1185
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1186
        ChargeSessionChartCardView(
1187
            session: session,
1188
            monitoringMeter: liveMonitoringMeter,
1189
            controlMode: chartControlMode(for: session),
1190
            onSetTrim: { start, end in
1191
                setSessionTrim(sessionID: session.id, start: start, end: end)
1192
            },
1193
            onStopWithTrim: { start, end in
1194
                requestStop(
1195
                    session,
1196
                    applyingTrimStart: start,
1197
                    trimEnd: end,
1198
                    title: "Trim End & Finish",
1199
                    confirmTitle: "Finish",
1200
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1201
                )
1202
            }
1203
        )
1204
    }
1205

            
1206
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1207
        if hasMonitoringControls {
1208
            return .activeMonitoring
1209
        }
1210

            
1211
        if session.status.isOpen == false {
1212
            return .closed
1213
        }
1214

            
1215
        return .none
1216
    }
1217

            
1218
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1219
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1220
        trimBannerDismissedForSessionID = sessionID
1221
    }
1222

            
1223
    private func requestStop(
1224
        _ session: ChargeSessionSummary,
1225
        applyingTrimStart trimStart: Date?,
1226
        trimEnd: Date?,
1227
        title: String,
1228
        confirmTitle: String,
1229
        explanation: String
1230
    ) {
1231
        pendingSessionStopRequest = ChargeSessionStopRequest(
1232
            sessionID: session.id,
1233
            title: title,
1234
            confirmTitle: confirmTitle,
1235
            explanation: explanation,
1236
            appliesTrim: trimStart != nil || trimEnd != nil,
1237
            trimStart: trimStart,
1238
            trimEnd: trimEnd
1239
        )
1240
    }
1241

            
1242
    private var parsedDraftTarget: Double? {
1243
        let normalized = draftTargetText
1244
            .trimmingCharacters(in: .whitespacesAndNewlines)
1245
            .replacingOccurrences(of: ",", with: ".")
1246
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1247
        return value
1248
    }
1249

            
1250
    private var parsedFinalCheckpoint: Double? {
1251
        let normalized = finalCheckpointText
1252
            .trimmingCharacters(in: .whitespacesAndNewlines)
1253
            .replacingOccurrences(of: ",", with: ".")
1254
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1255
        return value
1256
    }
1257

            
1258
    private var resolvedFinalCheckpoint: Double? {
1259
        switch finalCheckpointMode {
1260
        case .full:   return 100
1261
        case .skip:   return nil
1262
        case .custom: return parsedFinalCheckpoint
1263
        }
1264
    }
1265

            
1266
    private func adjustFinalCheckpoint(by delta: Double) {
1267
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1268
        let next = min(max(current + delta, 0), 100)
1269
        finalCheckpointText = next.format(decimalDigits: 0)
1270
    }
1271

            
1272
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1273
        guard let session else { return nil }
1274
        if let endBatteryPercent = session.endBatteryPercent {
1275
            return endBatteryPercent
1276
        }
1277
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1278
            return latestCheckpoint.batteryPercent
1279
        }
1280
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1281
    }
1282

            
1283
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1284
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1285
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1286
            return
1287
        }
1288
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1289
    }
1290

            
1291
    private func hasSavableChargeData(
1292
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1293
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1294
    ) -> Bool {
1295
        session.hasSavableChargeData
1296
            || displayedEnergyWh > 0
1297
    }
1298

            
1299
    private func saveDisabledReason(
1300
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1301
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1302
    ) -> String? {
1303
        if finalCheckpointMode == .custom {
1304
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1305
            if trimmed.isEmpty {
1306
                return "Enter the final battery percentage or choose Skip."
1307
            }
1308
            if parsedFinalCheckpoint == nil {
1309
                return "Final battery percentage must be between 0 and 100."
1310
            }
1311
        }
1312

            
1313
        guard hasSavableChargeData(
1314
            session: session,
Bogdan Timofte authored a month ago
1315
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1316
        ) else {
1317
            return "This session has no charging data to save. Discard it instead."
1318
        }
1319

            
1320
        return nil
1321
    }
1322

            
1323
    private func stopSession(
1324
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1325
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1326
    ) {
1327
        stopFailureMessage = nil
1328

            
1329
        if let saveDisabledReason = saveDisabledReason(
1330
            session: session,
Bogdan Timofte authored a month ago
1331
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1332
        ) {
1333
            stopFailureMessage = saveDisabledReason
1334
            return
1335
        }
1336

            
1337
        let didSave = appData.stopChargeSession(
1338
            sessionID: session.id,
1339
            finalBatteryPercent: resolvedFinalCheckpoint,
1340
            from: liveMonitoringMeter
1341
        )
1342
        if didSave {
1343
            resetStopConfirmation()
1344
        } else {
1345
            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."
1346
        }
1347
    }
1348

            
1349
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1350
        finalCheckpointMode = .skip
1351
        finalCheckpointText = ""
1352
        stopFailureMessage = nil
1353
        showingStopConfirm = true
1354
    }
1355

            
1356
    private func discardSession(_ session: ChargeSessionSummary) {
1357
        _ = appData.deleteChargeSession(sessionID: session.id)
1358
        resetStopConfirmation()
1359
    }
1360

            
1361
    private func resetStopConfirmation() {
1362
        showingStopConfirm = false
1363
        finalCheckpointText = ""
1364
        finalCheckpointMode = .skip
1365
        stopFailureMessage = nil
1366
    }
1367

            
1368
    private func syncMonitoringRestore() {
1369
        guard let session,
1370
              session.status.isOpen,
1371
              let liveMonitoringMeter,
1372
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1373
            return
1374
        }
1375
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1376
    }
1377

            
1378
    private func runTrimDetection() {
1379
        guard hasMonitoringControls,
1380
              let session,
1381
              session.isTrimmed == false,
1382
              !session.aggregatedSamples.isEmpty else {
1383
            detectedTrimWindow = nil
1384
            return
1385
        }
1386

            
1387
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1388
        detectedTrimWindow = ChargingWindowDetector.detect(
1389
            samples: session.aggregatedSamples,
1390
            sessionStart: session.startedAt,
1391
            sessionEnd: sessionEnd
1392
        )
1393
    }
1394

            
1395
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1396
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1397
        guard session.isTrimmed == false else { return storedEnergyWh }
1398
        guard session.status.isOpen else { return storedEnergyWh }
1399
        guard let liveMonitoringMeter else { return storedEnergyWh }
1400
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1401
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1402
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1403
        }
1404
        return storedEnergyWh
1405
    }
1406

            
1407
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1408
        let storedDuration = max(session.effectiveDuration, 0)
1409
        guard session.isTrimmed == false else { return storedDuration }
1410
        guard session.status.isOpen else { return storedDuration }
1411
        guard let liveMonitoringMeter else { return storedDuration }
1412
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1413
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1414
    }
1415

            
1416
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1417
        let displayedDuration = displayedSessionDuration(for: session)
1418
        let formatter = DateComponentsFormatter()
1419
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1420
        formatter.unitsStyle = .abbreviated
1421
        formatter.zeroFormattingBehavior = .dropAll
1422
        return formatter.string(from: displayedDuration) ?? "0m"
1423
    }
1424

            
1425
    private func formatDuration(_ duration: TimeInterval) -> String {
1426
        let totalSeconds = Int(duration.rounded(.down))
1427
        let hours = totalSeconds / 3600
1428
        let minutes = (totalSeconds % 3600) / 60
1429
        let seconds = totalSeconds % 60
1430
        if hours > 0 {
1431
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1432
        }
1433
        return String(format: "%02d:%02d", minutes, seconds)
1434
    }
1435

            
1436
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1437
        if session.autoStopEnabled == false {
1438
            return "Manual"
1439
        }
1440

            
1441
        if let sessionWarning = sessionWarning(for: session),
1442
           sessionWarning.contains("idle-current") {
1443
            return "Blocked by charger setup"
1444
        }
1445

            
1446
        if session.stopThresholdAmps > 0 {
1447
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1448
        }
1449

            
1450
        return "Learning"
1451
    }
1452

            
1453
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1454
        if session.autoStopEnabled == false {
1455
            return "Manual"
1456
        }
1457
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1458
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1459
        }
1460
        if session.stopThresholdAmps > 0 {
1461
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1462
        }
1463
        return "Learning"
1464
    }
1465

            
1466
    private func shouldShowChargingTransport(
1467
        for session: ChargeSessionSummary,
1468
        chargedDevice: ChargedDeviceSummary
1469
    ) -> Bool {
1470
        chargedDevice.supportedChargingModes.count > 1
1471
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1472
    }
1473

            
1474
    private func shouldShowChargingState(
1475
        for session: ChargeSessionSummary,
1476
        chargedDevice: ChargedDeviceSummary
1477
    ) -> Bool {
1478
        chargedDevice.supportedChargingStateModes.count > 1
1479
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1480
    }
1481

            
1482
    private func batteryColor(for percent: Double) -> Color {
1483
        if percent >= 75 { return .green }
1484
        if percent >= 35 { return .orange }
1485
        return .red
1486
    }
1487

            
1488
    private func etaText(
1489
        rateWhPerSec: Double?,
1490
        remainingWh: Double,
1491
        isRelevant: Bool
1492
    ) -> String? {
1493
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1494
        let seconds = remainingWh / rateWhPerSec
1495
        return seconds > 120 ? formatETA(seconds) : nil
1496
    }
1497

            
1498
    private func etaToTargetText(
1499
        session: ChargeSessionSummary,
1500
        prediction: BatteryLevelPrediction,
1501
        displayedEnergyWh: Double,
1502
        rateWhPerSec: Double?
1503
    ) -> String? {
1504
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1505
            return nil
1506
        }
1507
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1508
        return etaText(
1509
            rateWhPerSec: rateWhPerSec,
1510
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1511
            isRelevant: true
1512
        )
1513
    }
1514

            
1515
    private func formatETA(_ seconds: TimeInterval) -> String {
1516
        let totalMinutes = Int(seconds / 60)
1517
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1518
        let hours = totalMinutes / 60
1519
        let minutes = totalMinutes % 60
1520
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1521
    }
1522

            
1523
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1524
        switch session.status {
1525
        case .active:
1526
            return .red
1527
        case .paused:
1528
            return .orange
1529
        case .completed:
1530
            return .green
1531
        case .abandoned:
1532
            return .secondary
1533
        }
1534
    }
1535

            
1536
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1537
        nil
Bogdan Timofte authored a month ago
1538
    }
1539

            
1540
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1541
        guard session.chargingTransportMode == .wireless else {
1542
            return nil
1543
        }
1544

            
1545
        var components: [String] = []
1546
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1547
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1548
        }
1549
        if session.usesEstimatedWirelessEfficiency {
1550
            components.append("Estimated from wired baseline and checkpoints")
1551
        }
1552
        if session.shouldWarnAboutLowWirelessEfficiency {
1553
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1554
        }
1555

            
1556
        return components.isEmpty ? nil : components.joined(separator: " - ")
1557
    }
1558

            
1559
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1560
        switch session.status {
1561
        case .active:
1562
            return .green
1563
        case .paused:
1564
            return .orange
1565
        case .completed:
1566
            return .teal
1567
        case .abandoned:
1568
            return .secondary
1569
        }
1570
    }
1571
}
1572

            
1573
enum ChargeSessionChartControlMode {
1574
    case none
1575
    case activeMonitoring
1576
    case closed
1577
}
1578

            
1579
struct ChargeSessionChartCardView: View {
1580
    let session: ChargeSessionSummary
1581
    let monitoringMeter: Meter?
1582
    let controlMode: ChargeSessionChartControlMode
1583
    let onSetTrim: (Date?, Date?) -> Void
1584
    let onStopWithTrim: (Date?, Date?) -> Void
1585

            
1586
    @StateObject private var storedMeasurements = Measurements()
1587

            
1588
    private var chartMeasurements: Measurements {
1589
        if let monitoringMeter,
1590
           session.status.isOpen,
1591
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1592
            return monitoringMeter.chargeRecordMeasurements
1593
        }
1594
        return storedMeasurements
1595
    }
1596

            
1597
    private var fullTimeRange: ClosedRange<Date> {
1598
        let start = session.startedAt
1599
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1600
        return start...end
1601
    }
1602

            
1603
    private var fixedTimeRange: ClosedRange<Date>? {
1604
        if monitoringMeter != nil && session.status.isOpen {
1605
            return nil
1606
        }
1607
        return session.effectiveTimeRange
1608
    }
1609

            
1610
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1611
        guard monitoringMeter != nil && session.status.isOpen else {
1612
            return (nil, nil)
1613
        }
1614
        return (session.trimStart, session.trimEnd)
1615
    }
1616

            
1617
    private var showsRangeSelector: Bool {
1618
        controlMode != .none && !session.aggregatedSamples.isEmpty
1619
    }
1620

            
1621
    var body: some View {
1622
        VStack(alignment: .leading, spacing: 12) {
1623
            HStack(spacing: 8) {
1624
                Image(systemName: "chart.xyaxis.line")
1625
                    .foregroundColor(.blue)
1626
                Text("Session Chart")
1627
                    .font(.headline)
1628
                ContextInfoButton(
1629
                    title: "Session Chart",
1630
                    message: chartInfoMessage
1631
                )
1632
                Spacer(minLength: 0)
1633
            }
1634

            
1635
            MeasurementChartView(
1636
                timeRange: fixedTimeRange,
1637
                timeRangeLowerBound: liveTrimBounds.lower,
1638
                timeRangeUpperBound: liveTrimBounds.upper,
1639
                showsRangeSelector: showsRangeSelector,
1640
                rebasesEnergyToVisibleRangeStart: true,
1641
                extendsTimelineToPresent: false,
1642
                showsTemperatureSeries: false,
1643
                rangeSelectorConfiguration: rangeSelectorConfiguration
1644
            )
1645
            .environmentObject(chartMeasurements)
1646
            .frame(maxWidth: .infinity, alignment: .topLeading)
1647
        }
1648
        .padding(18)
1649
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1650
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1651
        .onChange(of: session.id) { _ in
1652
            restoreStoredMeasurementsIfNeeded()
1653
        }
1654
        .onChange(of: session.aggregatedSamples.count) { _ in
1655
            restoreStoredMeasurementsIfNeeded()
1656
        }
1657
    }
1658

            
1659
    private var chartInfoMessage: String {
1660
        if monitoringMeter != nil && session.status.isOpen {
1661
            return "This chart combines the persisted session curve with current live data from this meter."
1662
        }
1663

            
1664
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1665
    }
1666

            
1667
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1668
        switch controlMode {
1669
        case .none:
1670
            return nil
1671
        case .activeMonitoring:
1672
            return MeasurementChartRangeSelectorConfiguration(
1673
                keepAction: MeasurementChartSelectionAction(
1674
                    title: "Trim Start",
1675
                    shortTitle: "Start",
1676
                    systemName: "arrow.right.to.line",
1677
                    tone: .destructive,
1678
                    handler: applyActiveStartTrim
1679
                ),
1680
                removeAction: MeasurementChartSelectionAction(
1681
                    title: "Trim End & Finish",
1682
                    shortTitle: "End",
1683
                    systemName: "arrow.left.to.line",
1684
                    tone: .destructiveProminent,
1685
                    handler: requestActiveEndTrim
1686
                ),
1687
                resetAction: MeasurementChartResetAction(
1688
                    title: "Reset Trim",
1689
                    shortTitle: "Reset",
1690
                    systemName: "arrow.counterclockwise",
1691
                    tone: .reversible,
1692
                    confirmationTitle: "Reset session trim?",
1693
                    confirmationButtonTitle: "Reset trim",
1694
                    handler: {
1695
                        onSetTrim(nil, nil)
1696
                    }
1697
                )
1698
            )
1699
        case .closed:
1700
            return MeasurementChartRangeSelectorConfiguration(
1701
                keepAction: MeasurementChartSelectionAction(
1702
                    title: "Trim Window",
1703
                    shortTitle: "Trim",
1704
                    systemName: "scissors",
1705
                    tone: .destructive,
1706
                    handler: applyClosedTrim
1707
                ),
1708
                removeAction: nil,
1709
                resetAction: MeasurementChartResetAction(
1710
                    title: "Reset Trim",
1711
                    shortTitle: "Reset",
1712
                    systemName: "arrow.counterclockwise",
1713
                    tone: .reversible,
1714
                    confirmationTitle: "Reset session trim?",
1715
                    confirmationButtonTitle: "Reset trim",
1716
                    handler: {
1717
                        onSetTrim(nil, nil)
1718
                    }
1719
                )
1720
            )
1721
        }
1722
    }
1723

            
1724
    private func restoreStoredMeasurementsIfNeeded() {
1725
        guard monitoringMeter == nil || session.status.isOpen == false else {
1726
            return
1727
        }
1728
        storedMeasurements.resetSeries()
1729
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1730
            from: session,
1731
            replacingLiveBufferIfNeeded: true
1732
        )
1733
    }
1734

            
1735
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1736
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1737
    }
1738

            
1739
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1740
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1741
        let end = normalizedEnd(range.upperBound)
1742
        onStopWithTrim(start, end)
1743
    }
1744

            
1745
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1746
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1747
    }
1748

            
1749
    private func normalizedStart(_ date: Date) -> Date? {
1750
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1751
    }
1752

            
1753
    private func normalizedEnd(_ date: Date) -> Date? {
1754
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1755
    }
1756
}
1757

            
1758
private struct ChargeSessionStopRequest: Identifiable {
1759
    let sessionID: UUID
1760
    let title: String
1761
    let confirmTitle: String
1762
    let explanation: String
1763
    let appliesTrim: Bool
1764
    let trimStart: Date?
1765
    let trimEnd: Date?
1766

            
1767
    var id: String {
1768
        [
1769
            sessionID.uuidString,
1770
            title,
1771
            trimStart?.timeIntervalSince1970.description ?? "nil",
1772
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1773
        ].joined(separator: "-")
1774
    }
1775
}
1776

            
1777
private extension View {
1778
    func monitoringActionStyle(tint: Color) -> some View {
1779
        frame(maxWidth: .infinity)
1780
            .padding(.vertical, 10)
1781
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1782
            .buttonStyle(.plain)
1783
    }
1784

            
1785
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1786
        frame(maxWidth: .infinity)
1787
            .padding(.vertical, 9)
1788
            .meterCard(
1789
                tint: tint,
1790
                fillOpacity: isProminent ? 0.22 : 0.10,
1791
                strokeOpacity: isProminent ? 0.32 : 0.14,
1792
                cornerRadius: 14
1793
            )
1794
            .buttonStyle(.plain)
1795
    }
1796
}