Newer Older
1770 lines | 69.065kb
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 batteryPrediction = chargedDevice.batteryLevelPrediction(
251
            for: session,
252
            effectiveEnergyWhOverride: displayedEnergyWh
253
        )
254

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

            
260
                Spacer()
261

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

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

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

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

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

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

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

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

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

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

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

            
353
                Divider()
354

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

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

            
367
                Divider()
368

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

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

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

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

            
Bogdan Timofte authored a month ago
403
    private func overviewStatCell(label: String, value: String) -> some View {
404
        VStack(alignment: .leading, spacing: 2) {
405
            Text(label)
406
                .font(.caption2)
407
                .foregroundColor(.secondary)
408
            Text(value)
409
                .font(.footnote.weight(.medium))
410
                .monospacedDigit()
411
        }
412
        .frame(maxWidth: .infinity, alignment: .leading)
413
    }
414

            
Bogdan Timofte authored a month ago
415
    private func energyCard(
416
        _ session: ChargeSessionSummary,
417
        chargedDevice: ChargedDeviceSummary
418
    ) -> some View {
419
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
420

            
Bogdan Timofte authored a month ago
421
        return MeterInfoCardView(title: "Energy", tint: .teal, isCollapsible: true) {
Bogdan Timofte authored a month ago
422
            MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
423
            if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
424
                MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
425
            }
426
            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
427
               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
428
                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
429
            }
430
            if let capacityEstimateWh = session.capacityEstimateWh {
431
                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
432
            }
433
            if let chargerID = session.chargerID,
434
               let charger = appData.chargedDeviceSummary(id: chargerID) {
435
                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
436
            }
437
            if let wirelessSessionHint = wirelessSessionHint(for: session) {
438
                Text(wirelessSessionHint)
439
                    .font(.caption2)
440
                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
441
            }
442
            if let sessionWarning = sessionWarning(for: session) {
443
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
444
                    .font(.caption2)
445
                    .foregroundColor(.orange)
446
            }
447
        }
448
    }
449

            
450
    private func observedMetricsCard(
451
        _ session: ChargeSessionSummary,
452
        chargedDevice: ChargedDeviceSummary
453
    ) -> some View {
Bogdan Timofte authored a month ago
454
        MeterInfoCardView(title: "Observed Metrics", tint: .blue, isCollapsible: true, initiallyExpanded: false) {
Bogdan Timofte authored a month ago
455
            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
456
                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
457
            }
458
            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
459
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
460
            }
461
            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
462
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
463
            }
464
            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
465
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
466
            }
467
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
468
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
469
            }
470
            if let completionCurrentAmps = session.completionCurrentAmps {
471
                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
472
            }
473
            if session.selectedDataGroup != nil {
474
                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
475
            }
476
        }
477
    }
478

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

            
Bogdan Timofte authored a month ago
489
        return MeterInfoCardView(title: "Battery", tint: .orange, isCollapsible: true) {
Bogdan Timofte authored a month ago
490
            if let startBatteryPercent = session.startBatteryPercent {
491
                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
492
            }
493
            if let endBatteryPercent = session.endBatteryPercent {
494
                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
495
            }
496
            if let batteryDeltaPercent = session.batteryDeltaPercent {
497
                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
498
            }
499
            if let targetBatteryPercent = session.targetBatteryPercent {
500
                MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%")
501
            }
502
            if let batteryPrediction {
503
                MeterInfoRowView(
504
                    label: "Predicted Battery",
505
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
506
                )
507
                Text(
508
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
509
                )
510
                .font(.caption2)
511
                .foregroundColor(.secondary)
512
            }
513

            
514
            BatteryCheckpointSectionView(
515
                sessionID: session.id,
516
                checkpoints: session.checkpoints,
517
                message: session.status.isOpen
518
                    ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
519
                    : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
520
                canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
521
                canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
522
                requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
523
                effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
524
                onDelete: { checkpoint in
525
                    pendingCheckpointDeletion = checkpoint
526
                }
527
            )
528
        }
529
    }
530

            
531
    private func batteryGaugeSection(
532
        prediction: BatteryLevelPrediction,
533
        session: ChargeSessionSummary,
534
        displayedEnergyWh: Double
535
    ) -> some View {
536
        let percent = prediction.predictedPercent
537
        let color = batteryColor(for: percent)
538
        let duration = displayedSessionDuration(for: session)
539
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
540
            ? displayedEnergyWh / duration
541
            : nil
542
        let etaToFull = etaText(
543
            rateWhPerSec: rateWhPerSec,
544
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
545
            isRelevant: percent < 98
546
        )
547
        let etaToTarget = etaToTargetText(
548
            session: session,
549
            prediction: prediction,
550
            displayedEnergyWh: displayedEnergyWh,
551
            rateWhPerSec: rateWhPerSec
552
        )
553

            
554
        return VStack(spacing: 10) {
555
            HStack(alignment: .lastTextBaseline, spacing: 8) {
556
                HStack(alignment: .lastTextBaseline, spacing: 3) {
557
                    Text("\(Int(percent.rounded()))")
558
                        .font(.system(size: 52, weight: .bold, design: .rounded))
559
                        .foregroundColor(color)
560
                        .monospacedDigit()
561
                    Text("%")
562
                        .font(.title2.weight(.semibold))
563
                        .foregroundColor(color.opacity(0.8))
564
                }
565

            
566
                Spacer()
567

            
568
                VStack(alignment: .trailing, spacing: 2) {
569
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
570
                        .font(.callout.weight(.bold))
571
                        .foregroundColor(.orange)
572
                        .monospacedDigit()
573
                    Text("est. capacity")
574
                        .font(.caption2)
575
                        .foregroundColor(.secondary)
576
                }
577
            }
578

            
579
            batteryProgressBar(
580
                percent: percent,
581
                startPercent: session.startBatteryPercent,
582
                targetPercent: session.targetBatteryPercent
583
            )
584

            
585
            HStack(spacing: 14) {
586
                if let etaToFull {
587
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
588
                }
589

            
590
                if let etaToTarget, let target = session.targetBatteryPercent {
591
                    etaPill(
592
                        icon: "bell.badge.fill",
593
                        tint: .indigo,
594
                        value: etaToTarget,
595
                        label: "to \(Int(target.rounded()))%"
596
                    )
597
                }
598

            
599
                Spacer()
600

            
601
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
602
                    .font(.caption2)
603
                    .foregroundColor(.secondary)
604
                    .multilineTextAlignment(.trailing)
605
            }
606
        }
607
        .padding(14)
608
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
609
    }
610

            
611
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
612
        VStack(alignment: .leading, spacing: 1) {
613
            HStack(spacing: 4) {
614
                Image(systemName: icon)
615
                    .font(.caption)
616
                    .foregroundColor(tint)
617
                Text(value)
618
                    .font(.caption.weight(.bold))
619
            }
620
            Text(label)
621
                .font(.caption2)
622
                .foregroundColor(.secondary)
623
        }
624
    }
625

            
626
    private func batteryProgressBar(
627
        percent: Double,
628
        startPercent: Double?,
629
        targetPercent: Double?
630
    ) -> some View {
631
        let color = batteryColor(for: percent)
632
        return GeometryReader { geo in
633
            let width = geo.size.width
634
            ZStack(alignment: .leading) {
635
                Capsule()
636
                    .fill(Color.primary.opacity(0.10))
637
                Rectangle()
638
                    .fill(
639
                        LinearGradient(
640
                            colors: [color.opacity(0.6), color],
641
                            startPoint: .leading,
642
                            endPoint: .trailing
643
                        )
644
                    )
645
                    .frame(width: max(width * CGFloat(percent / 100), 4))
646
                    .animation(.easeInOut(duration: 0.4), value: percent)
647
                if let start = startPercent, start > 2, start < 98 {
648
                    Rectangle()
649
                        .fill(Color.white.opacity(0.55))
650
                        .frame(width: 2, height: 20)
651
                        .offset(x: width * CGFloat(start / 100) - 1)
652
                }
653
                if let target = targetPercent {
654
                    Rectangle()
655
                        .fill(Color.indigo.opacity(0.9))
656
                        .frame(width: 2.5, height: 20)
657
                        .offset(x: width * CGFloat(target / 100) - 1.25)
658
                }
659
            }
660
            .clipShape(Capsule())
661
        }
662
        .frame(height: 20)
663
    }
664

            
665
    private func sessionMetricsGrid(
666
        session: ChargeSessionSummary,
667
        chargedDevice: ChargedDeviceSummary,
668
        displayedEnergyWh: Double,
669
        hasPrediction: Bool
670
    ) -> some View {
671
        let capacityFallback: Double? = hasPrediction ? nil : (
672
            session.capacityEstimateWh
673
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
674
                ?? chargedDevice.estimatedBatteryCapacityWh
675
        )
676
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
677

            
678
        return LazyVGrid(columns: columns, spacing: 8) {
679
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
680
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
681

            
682
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
683
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
684
            }
685
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
686
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
687
            }
688

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

            
691
            if let capacityFallback {
692
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
693
            }
694
        }
695
    }
696

            
697
    private func metricCell(label: String, value: String, tint: Color) -> some View {
698
        VStack(alignment: .leading, spacing: 3) {
699
            Text(label)
700
                .font(.caption2)
701
                .foregroundColor(.secondary)
702
            Text(value)
703
                .font(.subheadline.weight(.semibold))
704
                .lineLimit(1)
705
                .minimumScaleFactor(0.7)
706
                .monospacedDigit()
707
        }
708
        .frame(maxWidth: .infinity, alignment: .leading)
709
        .padding(.horizontal, 12)
710
        .padding(.vertical, 10)
711
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
712
    }
713

            
714
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
715
        VStack(alignment: .leading, spacing: 10) {
716
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
717
                .font(.subheadline.weight(.semibold))
718

            
719
            if let contradictionPercent = session.completionContradictionPercent {
720
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
721
                    .font(.caption)
722
                    .foregroundColor(.secondary)
723
            } else {
724
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
725
                    .font(.caption)
726
                    .foregroundColor(.secondary)
727
            }
728

            
729
            HStack(spacing: 10) {
730
                Button("Finish") {
731
                    beginStopConfirmation(for: session)
732
                }
733
                .frame(maxWidth: .infinity)
734
                .padding(.vertical, 9)
735
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
736
                .buttonStyle(.plain)
737

            
738
                Button("Keep Monitoring") {
739
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
740
                }
741
                .frame(maxWidth: .infinity)
742
                .padding(.vertical, 9)
743
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
744
                .buttonStyle(.plain)
745
            }
746
        }
747
        .padding(14)
748
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
749
    }
750

            
751
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
752
        let draftBelowPrediction: Bool = {
753
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
754
            return draft <= predictedPercent
755
        }()
756
        let savedBelowPrediction: Bool = {
757
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
758
            return saved <= predictedPercent
759
        }()
760

            
761
        return HStack(alignment: .center, spacing: 8) {
762
            Image(systemName: "bell.badge")
763
                .foregroundColor(.indigo)
764
                .font(.subheadline)
765

            
766
            Text("Notify at")
767
                .font(.subheadline.weight(.semibold))
768

            
769
            Spacer(minLength: 8)
770

            
771
            if showingInlineTargetEditor {
772
                targetEditorControls(
773
                    session: session,
774
                    draftBelowPrediction: draftBelowPrediction,
775
                    predictedPercent: predictedPercent
776
                )
777
            } else {
778
                savedTargetControls(
779
                    session: session,
780
                    savedBelowPrediction: savedBelowPrediction,
781
                    predictedPercent: predictedPercent
782
                )
783
            }
784
        }
785
    }
786

            
787
    private func targetEditorControls(
788
        session: ChargeSessionSummary,
789
        draftBelowPrediction: Bool,
790
        predictedPercent: Double?
791
    ) -> some View {
792
        Group {
793
            Button {
794
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
795
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
796
            } label: {
797
                Image(systemName: "minus.circle")
798
                    .font(.title3)
799
            }
800
            .buttonStyle(.plain)
801

            
802
            TextField("-", text: $draftTargetText)
803
                .keyboardType(.decimalPad)
804
                .textFieldStyle(.roundedBorder)
805
                .frame(width: 48)
806
                .multilineTextAlignment(.center)
807
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
808

            
809
            Text("%")
810
                .font(.subheadline)
811
                .foregroundColor(.secondary)
812

            
813
            if draftBelowPrediction, let predictedPercent {
814
                predictionWarningButton(predictedPercent: predictedPercent)
815
            }
816

            
817
            Button {
818
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
819
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
820
            } label: {
821
                Image(systemName: "plus.circle")
822
                    .font(.title3)
823
            }
824
            .buttonStyle(.plain)
825

            
826
            Button {
827
                if let value = parsedDraftTarget {
828
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
829
                }
830
                showingInlineTargetEditor = false
831
            } label: {
832
                Image(systemName: "checkmark.circle.fill")
833
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
834
                    .font(.title3)
835
            }
836
            .buttonStyle(.plain)
837
            .disabled(parsedDraftTarget == nil)
838

            
839
            Button {
840
                showingInlineTargetEditor = false
841
                draftTargetText = ""
842
            } label: {
843
                Image(systemName: "xmark.circle")
844
                    .foregroundColor(.secondary)
845
                    .font(.title3)
846
            }
847
            .buttonStyle(.plain)
848
        }
849
    }
850

            
851
    private func savedTargetControls(
852
        session: ChargeSessionSummary,
853
        savedBelowPrediction: Bool,
854
        predictedPercent: Double?
855
    ) -> some View {
856
        Group {
857
            if let targetPercent = session.targetBatteryPercent {
858
                Text("\(targetPercent.format(decimalDigits: 0))%")
859
                    .font(.subheadline.weight(.semibold))
860
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
861

            
862
                if savedBelowPrediction, let predictedPercent {
863
                    predictionWarningButton(predictedPercent: predictedPercent)
864
                }
865

            
866
                Button {
867
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
868
                } label: {
869
                    Image(systemName: "xmark.circle.fill")
870
                        .foregroundColor(.secondary)
871
                        .font(.callout)
872
                }
873
                .buttonStyle(.plain)
874
                .help("Remove alert")
875
            }
876

            
877
            Button {
878
                draftTargetText = session.targetBatteryPercent.map {
879
                    $0.format(decimalDigits: 0)
880
                } ?? "80"
881
                showingInlineTargetEditor = true
882
            } label: {
883
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
884
                    .font(.caption.weight(.semibold))
885
                    .frame(width: 30, height: 30)
886
                    .contentShape(Rectangle())
887
            }
888
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
889
            .buttonStyle(.plain)
890
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
891
        }
892
    }
893

            
894
    private func predictionWarningButton(predictedPercent: Double) -> some View {
895
        Button {} label: {
896
            Image(systemName: "exclamationmark.triangle.fill")
897
                .font(.callout.weight(.semibold))
898
                .foregroundColor(.orange)
899
        }
900
        .buttonStyle(.plain)
901
        .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.")
902
    }
903

            
904
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
905
        HStack(spacing: 10) {
906
            if session.status == .active {
907
                Button("Pause") {
908
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
909
                }
910
                .monitoringActionStyle(tint: .orange)
911
            } else if session.status == .paused {
912
                Button("Resume") {
913
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
914
                }
915
                .monitoringActionStyle(tint: .blue)
916
            }
917

            
918
            Button("Terminate Session") {
919
                beginStopConfirmation(for: session)
920
            }
921
            .monitoringActionStyle(tint: .red)
922
        }
923
    }
924

            
925
    private func stopConfirmPanel(
926
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
927
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
928
    ) -> some View {
929
        let canSave = hasSavableChargeData(
930
            session: session,
Bogdan Timofte authored a month ago
931
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
932
        )
933
        let saveDisabledReason = saveDisabledReason(
934
            session: session,
Bogdan Timofte authored a month ago
935
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
936
        )
937
        let isSaveEnabled = saveDisabledReason == nil
938

            
939
        return VStack(alignment: .leading, spacing: 12) {
940
            HStack {
941
                Text("Final Checkpoint")
942
                    .font(.subheadline.weight(.semibold))
943
                Text("optional")
944
                    .font(.caption2.weight(.semibold))
945
                    .foregroundColor(.secondary)
946
            }
947

            
948
            finalCheckpointPicker(session)
949

            
950
            if finalCheckpointMode == .custom {
951
                customFinalCheckpointRow
952
            }
953

            
954
            if let saveDisabledReason {
955
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
956
                    .font(.caption)
957
                    .foregroundColor(.red)
958
                    .fixedSize(horizontal: false, vertical: true)
959
            } else if let stopFailureMessage {
960
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
961
                    .font(.caption)
962
                    .foregroundColor(.red)
963
                    .fixedSize(horizontal: false, vertical: true)
964
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
965
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
966
                    .font(.caption)
967
                    .foregroundColor(.green)
968
                    .fixedSize(horizontal: false, vertical: true)
969
            }
970

            
971
            HStack(spacing: 8) {
972
                Button("Discard") {
973
                    discardSession(session)
974
                }
975
                .monitoringPanelActionStyle(tint: .secondary)
976

            
977
                Button {
978
                    stopSession(
979
                        session,
Bogdan Timofte authored a month ago
980
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
981
                    )
982
                } label: {
983
                    Label("Save Session", systemImage: "checkmark.circle.fill")
984
                        .frame(maxWidth: .infinity)
985
                }
986
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
987
                .disabled(!isSaveEnabled)
988
                .help(saveDisabledReason ?? "Close and save this session")
989

            
990
                Button("Cancel") {
991
                    resetStopConfirmation()
992
                }
993
                .monitoringPanelActionStyle(tint: .secondary)
994
            }
995
        }
996
        .padding(14)
997
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
998
    }
999

            
1000
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1001
        return HStack(spacing: 8) {
1002
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1003
                Button {
1004
                    finalCheckpointMode = mode
1005
                    if mode == .custom {
1006
                        prefillFinalCheckpointIfNeeded(for: session)
1007
                    } else {
1008
                        finalCheckpointText = ""
1009
                    }
1010
                } label: {
1011
                    VStack(spacing: 5) {
1012
                        Image(systemName: mode.icon)
1013
                            .font(.title3)
1014
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1015
                        Text(mode.label)
1016
                            .font(.caption.weight(.semibold))
1017
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1018
                    }
1019
                    .frame(maxWidth: .infinity)
1020
                    .padding(.vertical, 10)
1021
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1022
                    .meterCard(
1023
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1024
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1025
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1026
                        cornerRadius: 12
1027
                    )
1028
                }
1029
                .buttonStyle(.plain)
1030
            }
1031
        }
1032
    }
1033

            
1034
    private var customFinalCheckpointRow: some View {
1035
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1036
            || parsedFinalCheckpoint == nil
1037

            
1038
        return HStack(spacing: 8) {
1039
            Button {
1040
                adjustFinalCheckpoint(by: -1)
1041
            } label: {
1042
                Image(systemName: "minus.circle").font(.title3)
1043
            }
1044
            .buttonStyle(.plain)
1045

            
1046
            TextField("-", text: $finalCheckpointText)
1047
                .keyboardType(.decimalPad)
1048
                .textFieldStyle(.roundedBorder)
1049
                .frame(width: 56)
1050
                .multilineTextAlignment(.center)
1051
                .overlay(
1052
                    RoundedRectangle(cornerRadius: 6)
1053
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1054
                )
1055

            
1056
            Text("%").foregroundColor(.secondary)
1057

            
1058
            Text("required")
1059
                .font(.caption2.weight(.semibold))
1060
                .foregroundColor(isInvalid ? .red : .secondary)
1061

            
1062
            Button {
1063
                adjustFinalCheckpoint(by: 1)
1064
            } label: {
1065
                Image(systemName: "plus.circle").font(.title3)
1066
            }
1067
            .buttonStyle(.plain)
1068

            
1069
            Spacer()
1070
        }
1071
    }
1072

            
1073
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1074
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1075
            if let meterName = session.meterName {
1076
                MeterInfoRowView(label: "Controlled On", value: meterName)
1077
            }
1078
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1079
                .font(.caption2)
1080
                .foregroundColor(.secondary)
1081
        }
1082
    }
1083

            
1084
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1085
        MeterInfoCardView(title: "Administration", tint: .red) {
1086
            Button(role: .destructive) {
1087
                pendingSessionDeletion = session
1088
            } label: {
1089
                Label("Delete Session", systemImage: "trash")
1090
                    .font(.subheadline.weight(.semibold))
1091
                    .frame(maxWidth: .infinity)
1092
                    .padding(.vertical, 10)
1093
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1094
            }
1095
            .buttonStyle(.plain)
1096
        }
1097
    }
1098

            
1099
    @ViewBuilder
1100
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1101
        if let window = detectedTrimWindow {
1102
            HStack(spacing: 12) {
1103
                Image(systemName: "scissors.circle.fill")
1104
                    .font(.title3)
1105
                    .foregroundColor(.blue)
1106

            
1107
                VStack(alignment: .leading, spacing: 2) {
1108
                    Text("Charging ended early")
1109
                        .font(.subheadline.weight(.semibold))
1110
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1111
                        .font(.caption)
1112
                        .foregroundColor(.secondary)
1113
                        .fixedSize(horizontal: false, vertical: true)
1114
                }
1115

            
1116
                Spacer(minLength: 0)
1117

            
1118
                VStack(spacing: 6) {
1119
                    Button("Trim Start") {
1120
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1121
                        trimBannerDismissedForSessionID = session.id
1122
                    }
1123
                    .font(.caption.weight(.semibold))
1124
                    .buttonStyle(.borderedProminent)
1125
                    .controlSize(.small)
1126
                    .tint(.blue)
1127

            
1128
                    Button("End & Finish") {
1129
                        requestStop(
1130
                            session,
1131
                            applyingTrimStart: session.trimStart ?? window.start,
1132
                            trimEnd: window.end,
1133
                            title: "Trim End & Finish",
1134
                            confirmTitle: "Finish",
1135
                            explanation: "The detected charging window will be saved before the session is closed."
1136
                        )
1137
                        trimBannerDismissedForSessionID = session.id
1138
                    }
1139
                    .font(.caption.weight(.semibold))
1140
                    .buttonStyle(.bordered)
1141
                    .controlSize(.small)
1142
                    .tint(.red)
1143
                }
1144
            }
1145
            .padding(14)
1146
            .background(
1147
                RoundedRectangle(cornerRadius: 14)
1148
                    .fill(Color.blue.opacity(0.10))
1149
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1150
            )
1151
            .transition(.opacity.combined(with: .move(edge: .top)))
1152
        }
1153
    }
1154

            
1155
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1156
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1157
    }
1158

            
1159
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1160
        ChargeSessionChartCardView(
1161
            session: session,
1162
            monitoringMeter: liveMonitoringMeter,
1163
            controlMode: chartControlMode(for: session),
1164
            onSetTrim: { start, end in
1165
                setSessionTrim(sessionID: session.id, start: start, end: end)
1166
            },
1167
            onStopWithTrim: { start, end in
1168
                requestStop(
1169
                    session,
1170
                    applyingTrimStart: start,
1171
                    trimEnd: end,
1172
                    title: "Trim End & Finish",
1173
                    confirmTitle: "Finish",
1174
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1175
                )
1176
            }
1177
        )
1178
    }
1179

            
1180
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1181
        if hasMonitoringControls {
1182
            return .activeMonitoring
1183
        }
1184

            
1185
        if session.status.isOpen == false {
1186
            return .closed
1187
        }
1188

            
1189
        return .none
1190
    }
1191

            
1192
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1193
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1194
        trimBannerDismissedForSessionID = sessionID
1195
    }
1196

            
1197
    private func requestStop(
1198
        _ session: ChargeSessionSummary,
1199
        applyingTrimStart trimStart: Date?,
1200
        trimEnd: Date?,
1201
        title: String,
1202
        confirmTitle: String,
1203
        explanation: String
1204
    ) {
1205
        pendingSessionStopRequest = ChargeSessionStopRequest(
1206
            sessionID: session.id,
1207
            title: title,
1208
            confirmTitle: confirmTitle,
1209
            explanation: explanation,
1210
            appliesTrim: trimStart != nil || trimEnd != nil,
1211
            trimStart: trimStart,
1212
            trimEnd: trimEnd
1213
        )
1214
    }
1215

            
1216
    private var parsedDraftTarget: Double? {
1217
        let normalized = draftTargetText
1218
            .trimmingCharacters(in: .whitespacesAndNewlines)
1219
            .replacingOccurrences(of: ",", with: ".")
1220
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1221
        return value
1222
    }
1223

            
1224
    private var parsedFinalCheckpoint: Double? {
1225
        let normalized = finalCheckpointText
1226
            .trimmingCharacters(in: .whitespacesAndNewlines)
1227
            .replacingOccurrences(of: ",", with: ".")
1228
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1229
        return value
1230
    }
1231

            
1232
    private var resolvedFinalCheckpoint: Double? {
1233
        switch finalCheckpointMode {
1234
        case .full:   return 100
1235
        case .skip:   return nil
1236
        case .custom: return parsedFinalCheckpoint
1237
        }
1238
    }
1239

            
1240
    private func adjustFinalCheckpoint(by delta: Double) {
1241
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1242
        let next = min(max(current + delta, 0), 100)
1243
        finalCheckpointText = next.format(decimalDigits: 0)
1244
    }
1245

            
1246
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1247
        guard let session else { return nil }
1248
        if let endBatteryPercent = session.endBatteryPercent {
1249
            return endBatteryPercent
1250
        }
1251
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1252
            return latestCheckpoint.batteryPercent
1253
        }
1254
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1255
    }
1256

            
1257
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1258
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1259
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1260
            return
1261
        }
1262
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1263
    }
1264

            
1265
    private func hasSavableChargeData(
1266
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1267
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1268
    ) -> Bool {
1269
        session.hasSavableChargeData
1270
            || displayedEnergyWh > 0
1271
    }
1272

            
1273
    private func saveDisabledReason(
1274
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1275
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1276
    ) -> String? {
1277
        if finalCheckpointMode == .custom {
1278
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1279
            if trimmed.isEmpty {
1280
                return "Enter the final battery percentage or choose Skip."
1281
            }
1282
            if parsedFinalCheckpoint == nil {
1283
                return "Final battery percentage must be between 0 and 100."
1284
            }
1285
        }
1286

            
1287
        guard hasSavableChargeData(
1288
            session: session,
Bogdan Timofte authored a month ago
1289
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1290
        ) else {
1291
            return "This session has no charging data to save. Discard it instead."
1292
        }
1293

            
1294
        return nil
1295
    }
1296

            
1297
    private func stopSession(
1298
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1299
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1300
    ) {
1301
        stopFailureMessage = nil
1302

            
1303
        if let saveDisabledReason = saveDisabledReason(
1304
            session: session,
Bogdan Timofte authored a month ago
1305
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1306
        ) {
1307
            stopFailureMessage = saveDisabledReason
1308
            return
1309
        }
1310

            
1311
        let didSave = appData.stopChargeSession(
1312
            sessionID: session.id,
1313
            finalBatteryPercent: resolvedFinalCheckpoint,
1314
            from: liveMonitoringMeter
1315
        )
1316
        if didSave {
1317
            resetStopConfirmation()
1318
        } else {
1319
            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."
1320
        }
1321
    }
1322

            
1323
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1324
        finalCheckpointMode = .skip
1325
        finalCheckpointText = ""
1326
        stopFailureMessage = nil
1327
        showingStopConfirm = true
1328
    }
1329

            
1330
    private func discardSession(_ session: ChargeSessionSummary) {
1331
        _ = appData.deleteChargeSession(sessionID: session.id)
1332
        resetStopConfirmation()
1333
    }
1334

            
1335
    private func resetStopConfirmation() {
1336
        showingStopConfirm = false
1337
        finalCheckpointText = ""
1338
        finalCheckpointMode = .skip
1339
        stopFailureMessage = nil
1340
    }
1341

            
1342
    private func syncMonitoringRestore() {
1343
        guard let session,
1344
              session.status.isOpen,
1345
              let liveMonitoringMeter,
1346
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1347
            return
1348
        }
1349
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1350
    }
1351

            
1352
    private func runTrimDetection() {
1353
        guard hasMonitoringControls,
1354
              let session,
1355
              session.isTrimmed == false,
1356
              !session.aggregatedSamples.isEmpty else {
1357
            detectedTrimWindow = nil
1358
            return
1359
        }
1360

            
1361
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1362
        detectedTrimWindow = ChargingWindowDetector.detect(
1363
            samples: session.aggregatedSamples,
1364
            sessionStart: session.startedAt,
1365
            sessionEnd: sessionEnd
1366
        )
1367
    }
1368

            
1369
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1370
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1371
        guard session.isTrimmed == false else { return storedEnergyWh }
1372
        guard session.status.isOpen else { return storedEnergyWh }
1373
        guard let liveMonitoringMeter else { return storedEnergyWh }
1374
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1375
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1376
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1377
        }
1378
        return storedEnergyWh
1379
    }
1380

            
1381
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1382
        let storedDuration = max(session.effectiveDuration, 0)
1383
        guard session.isTrimmed == false else { return storedDuration }
1384
        guard session.status.isOpen else { return storedDuration }
1385
        guard let liveMonitoringMeter else { return storedDuration }
1386
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1387
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1388
    }
1389

            
1390
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1391
        let displayedDuration = displayedSessionDuration(for: session)
1392
        let formatter = DateComponentsFormatter()
1393
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1394
        formatter.unitsStyle = .abbreviated
1395
        formatter.zeroFormattingBehavior = .dropAll
1396
        return formatter.string(from: displayedDuration) ?? "0m"
1397
    }
1398

            
1399
    private func formatDuration(_ duration: TimeInterval) -> String {
1400
        let totalSeconds = Int(duration.rounded(.down))
1401
        let hours = totalSeconds / 3600
1402
        let minutes = (totalSeconds % 3600) / 60
1403
        let seconds = totalSeconds % 60
1404
        if hours > 0 {
1405
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1406
        }
1407
        return String(format: "%02d:%02d", minutes, seconds)
1408
    }
1409

            
1410
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1411
        if session.autoStopEnabled == false {
1412
            return "Manual"
1413
        }
1414

            
1415
        if let sessionWarning = sessionWarning(for: session),
1416
           sessionWarning.contains("idle-current") {
1417
            return "Blocked by charger setup"
1418
        }
1419

            
1420
        if session.stopThresholdAmps > 0 {
1421
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1422
        }
1423

            
1424
        return "Learning"
1425
    }
1426

            
1427
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1428
        if session.autoStopEnabled == false {
1429
            return "Manual"
1430
        }
1431
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1432
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1433
        }
1434
        if session.stopThresholdAmps > 0 {
1435
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1436
        }
1437
        return "Learning"
1438
    }
1439

            
1440
    private func shouldShowChargingTransport(
1441
        for session: ChargeSessionSummary,
1442
        chargedDevice: ChargedDeviceSummary
1443
    ) -> Bool {
1444
        chargedDevice.supportedChargingModes.count > 1
1445
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1446
    }
1447

            
1448
    private func shouldShowChargingState(
1449
        for session: ChargeSessionSummary,
1450
        chargedDevice: ChargedDeviceSummary
1451
    ) -> Bool {
1452
        chargedDevice.supportedChargingStateModes.count > 1
1453
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1454
    }
1455

            
1456
    private func batteryColor(for percent: Double) -> Color {
1457
        if percent >= 75 { return .green }
1458
        if percent >= 35 { return .orange }
1459
        return .red
1460
    }
1461

            
1462
    private func etaText(
1463
        rateWhPerSec: Double?,
1464
        remainingWh: Double,
1465
        isRelevant: Bool
1466
    ) -> String? {
1467
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1468
        let seconds = remainingWh / rateWhPerSec
1469
        return seconds > 120 ? formatETA(seconds) : nil
1470
    }
1471

            
1472
    private func etaToTargetText(
1473
        session: ChargeSessionSummary,
1474
        prediction: BatteryLevelPrediction,
1475
        displayedEnergyWh: Double,
1476
        rateWhPerSec: Double?
1477
    ) -> String? {
1478
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1479
            return nil
1480
        }
1481
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1482
        return etaText(
1483
            rateWhPerSec: rateWhPerSec,
1484
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1485
            isRelevant: true
1486
        )
1487
    }
1488

            
1489
    private func formatETA(_ seconds: TimeInterval) -> String {
1490
        let totalMinutes = Int(seconds / 60)
1491
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1492
        let hours = totalMinutes / 60
1493
        let minutes = totalMinutes % 60
1494
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1495
    }
1496

            
1497
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1498
        switch session.status {
1499
        case .active:
1500
            return .red
1501
        case .paused:
1502
            return .orange
1503
        case .completed:
1504
            return .green
1505
        case .abandoned:
1506
            return .secondary
1507
        }
1508
    }
1509

            
1510
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1511
        nil
Bogdan Timofte authored a month ago
1512
    }
1513

            
1514
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1515
        guard session.chargingTransportMode == .wireless else {
1516
            return nil
1517
        }
1518

            
1519
        var components: [String] = []
1520
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1521
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1522
        }
1523
        if session.usesEstimatedWirelessEfficiency {
1524
            components.append("Estimated from wired baseline and checkpoints")
1525
        }
1526
        if session.shouldWarnAboutLowWirelessEfficiency {
1527
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1528
        }
1529

            
1530
        return components.isEmpty ? nil : components.joined(separator: " - ")
1531
    }
1532

            
1533
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1534
        switch session.status {
1535
        case .active:
1536
            return .green
1537
        case .paused:
1538
            return .orange
1539
        case .completed:
1540
            return .teal
1541
        case .abandoned:
1542
            return .secondary
1543
        }
1544
    }
1545
}
1546

            
1547
enum ChargeSessionChartControlMode {
1548
    case none
1549
    case activeMonitoring
1550
    case closed
1551
}
1552

            
1553
struct ChargeSessionChartCardView: View {
1554
    let session: ChargeSessionSummary
1555
    let monitoringMeter: Meter?
1556
    let controlMode: ChargeSessionChartControlMode
1557
    let onSetTrim: (Date?, Date?) -> Void
1558
    let onStopWithTrim: (Date?, Date?) -> Void
1559

            
1560
    @StateObject private var storedMeasurements = Measurements()
1561

            
1562
    private var chartMeasurements: Measurements {
1563
        if let monitoringMeter,
1564
           session.status.isOpen,
1565
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1566
            return monitoringMeter.chargeRecordMeasurements
1567
        }
1568
        return storedMeasurements
1569
    }
1570

            
1571
    private var fullTimeRange: ClosedRange<Date> {
1572
        let start = session.startedAt
1573
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1574
        return start...end
1575
    }
1576

            
1577
    private var fixedTimeRange: ClosedRange<Date>? {
1578
        if monitoringMeter != nil && session.status.isOpen {
1579
            return nil
1580
        }
1581
        return session.effectiveTimeRange
1582
    }
1583

            
1584
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1585
        guard monitoringMeter != nil && session.status.isOpen else {
1586
            return (nil, nil)
1587
        }
1588
        return (session.trimStart, session.trimEnd)
1589
    }
1590

            
1591
    private var showsRangeSelector: Bool {
1592
        controlMode != .none && !session.aggregatedSamples.isEmpty
1593
    }
1594

            
1595
    var body: some View {
1596
        VStack(alignment: .leading, spacing: 12) {
1597
            HStack(spacing: 8) {
1598
                Image(systemName: "chart.xyaxis.line")
1599
                    .foregroundColor(.blue)
1600
                Text("Session Chart")
1601
                    .font(.headline)
1602
                ContextInfoButton(
1603
                    title: "Session Chart",
1604
                    message: chartInfoMessage
1605
                )
1606
                Spacer(minLength: 0)
1607
            }
1608

            
1609
            MeasurementChartView(
1610
                timeRange: fixedTimeRange,
1611
                timeRangeLowerBound: liveTrimBounds.lower,
1612
                timeRangeUpperBound: liveTrimBounds.upper,
1613
                showsRangeSelector: showsRangeSelector,
1614
                rebasesEnergyToVisibleRangeStart: true,
1615
                extendsTimelineToPresent: false,
1616
                showsTemperatureSeries: false,
1617
                rangeSelectorConfiguration: rangeSelectorConfiguration
1618
            )
1619
            .environmentObject(chartMeasurements)
1620
            .frame(maxWidth: .infinity, alignment: .topLeading)
1621
        }
1622
        .padding(18)
1623
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1624
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1625
        .onChange(of: session.id) { _ in
1626
            restoreStoredMeasurementsIfNeeded()
1627
        }
1628
        .onChange(of: session.aggregatedSamples.count) { _ in
1629
            restoreStoredMeasurementsIfNeeded()
1630
        }
1631
    }
1632

            
1633
    private var chartInfoMessage: String {
1634
        if monitoringMeter != nil && session.status.isOpen {
1635
            return "This chart combines the persisted session curve with current live data from this meter."
1636
        }
1637

            
1638
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1639
    }
1640

            
1641
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1642
        switch controlMode {
1643
        case .none:
1644
            return nil
1645
        case .activeMonitoring:
1646
            return MeasurementChartRangeSelectorConfiguration(
1647
                keepAction: MeasurementChartSelectionAction(
1648
                    title: "Trim Start",
1649
                    shortTitle: "Start",
1650
                    systemName: "arrow.right.to.line",
1651
                    tone: .destructive,
1652
                    handler: applyActiveStartTrim
1653
                ),
1654
                removeAction: MeasurementChartSelectionAction(
1655
                    title: "Trim End & Finish",
1656
                    shortTitle: "End",
1657
                    systemName: "arrow.left.to.line",
1658
                    tone: .destructiveProminent,
1659
                    handler: requestActiveEndTrim
1660
                ),
1661
                resetAction: MeasurementChartResetAction(
1662
                    title: "Reset Trim",
1663
                    shortTitle: "Reset",
1664
                    systemName: "arrow.counterclockwise",
1665
                    tone: .reversible,
1666
                    confirmationTitle: "Reset session trim?",
1667
                    confirmationButtonTitle: "Reset trim",
1668
                    handler: {
1669
                        onSetTrim(nil, nil)
1670
                    }
1671
                )
1672
            )
1673
        case .closed:
1674
            return MeasurementChartRangeSelectorConfiguration(
1675
                keepAction: MeasurementChartSelectionAction(
1676
                    title: "Trim Window",
1677
                    shortTitle: "Trim",
1678
                    systemName: "scissors",
1679
                    tone: .destructive,
1680
                    handler: applyClosedTrim
1681
                ),
1682
                removeAction: nil,
1683
                resetAction: MeasurementChartResetAction(
1684
                    title: "Reset Trim",
1685
                    shortTitle: "Reset",
1686
                    systemName: "arrow.counterclockwise",
1687
                    tone: .reversible,
1688
                    confirmationTitle: "Reset session trim?",
1689
                    confirmationButtonTitle: "Reset trim",
1690
                    handler: {
1691
                        onSetTrim(nil, nil)
1692
                    }
1693
                )
1694
            )
1695
        }
1696
    }
1697

            
1698
    private func restoreStoredMeasurementsIfNeeded() {
1699
        guard monitoringMeter == nil || session.status.isOpen == false else {
1700
            return
1701
        }
1702
        storedMeasurements.resetSeries()
1703
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1704
            from: session,
1705
            replacingLiveBufferIfNeeded: true
1706
        )
1707
    }
1708

            
1709
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1710
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1711
    }
1712

            
1713
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1714
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1715
        let end = normalizedEnd(range.upperBound)
1716
        onStopWithTrim(start, end)
1717
    }
1718

            
1719
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1720
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1721
    }
1722

            
1723
    private func normalizedStart(_ date: Date) -> Date? {
1724
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1725
    }
1726

            
1727
    private func normalizedEnd(_ date: Date) -> Date? {
1728
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1729
    }
1730
}
1731

            
1732
private struct ChargeSessionStopRequest: Identifiable {
1733
    let sessionID: UUID
1734
    let title: String
1735
    let confirmTitle: String
1736
    let explanation: String
1737
    let appliesTrim: Bool
1738
    let trimStart: Date?
1739
    let trimEnd: Date?
1740

            
1741
    var id: String {
1742
        [
1743
            sessionID.uuidString,
1744
            title,
1745
            trimStart?.timeIntervalSince1970.description ?? "nil",
1746
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1747
        ].joined(separator: "-")
1748
    }
1749
}
1750

            
1751
private extension View {
1752
    func monitoringActionStyle(tint: Color) -> some View {
1753
        frame(maxWidth: .infinity)
1754
            .padding(.vertical, 10)
1755
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1756
            .buttonStyle(.plain)
1757
    }
1758

            
1759
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1760
        frame(maxWidth: .infinity)
1761
            .padding(.vertical, 9)
1762
            .meterCard(
1763
                tint: tint,
1764
                fillOpacity: isProminent ? 0.22 : 0.10,
1765
                strokeOpacity: isProminent ? 0.32 : 0.14,
1766
                cornerRadius: 14
1767
            )
1768
            .buttonStyle(.plain)
1769
    }
1770
}