Newer Older
1963 lines | 77.616kb
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?
Bogdan Timofte authored a month ago
48
    @State private var pendingTrimCommitSession: ChargeSessionSummary?
Bogdan Timofte authored a month ago
49
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
50
    @State private var trimBannerDismissedForSessionID: UUID?
51
    @State private var showingInlineTargetEditor = false
52
    @State private var draftTargetText = ""
53
    @State private var showingStopConfirm = false
54
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
55
    @State private var isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
56
    @State private var finalCheckpointText = ""
57
    @State private var stopFailureMessage: String?
58

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

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

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

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

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

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

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

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

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

            
196
    private func content(
197
        chargedDevice: ChargedDeviceSummary,
198
        session: ChargeSessionSummary
199
    ) -> some View {
200
        ScrollView {
201
            VStack(spacing: 16) {
202
                if hasMonitoringControls {
203
                    monitoringSessionCard(session, chargedDevice: chargedDevice)
204

            
205
                    if shouldShowTrimBanner {
206
                        trimDetectionBanner(session)
207
                    }
208

            
209
                    if shouldShowSessionChart(session) {
210
                        chartCard(session)
211
                    }
212
                } else {
213
                    overviewCard(session, chargedDevice: chargedDevice)
214
                    batteryCard(session, chargedDevice: chargedDevice)
215

            
216
                    if shouldShowSessionChart(session) {
217
                        chartCard(session)
218
                    }
219

            
220
                    if session.status.isOpen {
221
                        followerNoticeCard(session)
222
                    }
223
                }
224
            }
225
            .padding(presentation == .embedded ? 16 : 20)
226
        }
227
        .background(
228
            LinearGradient(
229
                colors: [statusTint(for: session).opacity(0.14), Color.clear],
230
                startPoint: .topLeading,
231
                endPoint: .bottomTrailing
232
            )
233
            .ignoresSafeArea()
234
        )
235
        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
Bogdan Timofte authored a month ago
236
        .toolbar {
237
            ToolbarItemGroup(placement: .primaryAction) {
238
                if session.status.isOpen == false {
239
                    Button(role: .destructive) {
240
                        pendingSessionDeletion = session
241
                    } label: {
242
                        Image(systemName: "trash")
243
                    }
244
                    .help("Delete session")
245
                }
246
            }
247
        }
Bogdan Timofte authored a month ago
248
    }
249

            
250
    private var unavailableState: some View {
251
        VStack(spacing: 12) {
252
            Image(systemName: "bolt.slash")
253
                .font(.title2)
254
                .foregroundColor(.secondary)
255
            Text("This session is no longer available.")
256
                .font(.headline)
257
            Text("It may have been deleted or synced from another device.")
258
                .font(.footnote)
259
                .foregroundColor(.secondary)
260
                .multilineTextAlignment(.center)
261
        }
262
        .frame(maxWidth: .infinity, maxHeight: .infinity)
263
        .padding(24)
264
        .navigationTitle("Session")
265
    }
266

            
267
    private func monitoringSessionCard(
268
        _ session: ChargeSessionSummary,
269
        chargedDevice: ChargedDeviceSummary
270
    ) -> some View {
271
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
272
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
273
            for: session,
274
            effectiveEnergyWhOverride: displayedEnergyWh
275
        )
276

            
277
        return VStack(alignment: .leading, spacing: 14) {
278
            HStack {
279
                ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
280
                    .font(.headline)
281

            
282
                Spacer()
283

            
284
                Text(session.status.title)
285
                    .font(.caption.weight(.bold))
286
                    .foregroundColor(monitoringStatusColor(for: session))
287
                    .padding(.horizontal, 8)
288
                    .padding(.vertical, 4)
289
                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
290
            }
291

            
292
            if let batteryPrediction {
293
                batteryGaugeSection(
294
                    prediction: batteryPrediction,
295
                    session: session,
296
                    displayedEnergyWh: displayedEnergyWh
297
                )
298
            }
299

            
300
            sessionMetricsGrid(
301
                session: session,
302
                chargedDevice: chargedDevice,
303
                displayedEnergyWh: displayedEnergyWh,
304
                hasPrediction: batteryPrediction != nil
305
            )
306

            
307
            if session.stopThresholdAmps > 0 {
308
                Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
309
                    .font(.caption)
310
                    .foregroundColor(.secondary)
311
            }
312

            
313
            if let sessionWarning = sessionWarning(for: session) {
314
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
315
                    .font(.caption)
316
                    .foregroundColor(.orange)
317
            }
318

            
319
            if session.isPaused {
320
                Label(
321
                    "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
322
                    systemImage: "pause.circle"
323
                )
324
                .font(.caption)
325
                .foregroundColor(.secondary)
326
            }
327

            
328
            if session.requiresCompletionConfirmation && !showingStopConfirm {
329
                completionConfirmationCard(session)
330
            }
331

            
332
            BatteryCheckpointSectionView(
333
                sessionID: session.id,
334
                checkpoints: session.checkpoints,
335
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
336
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
337
                canDeleteCheckpoint: true,
338
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
339
                effectiveEnergyWhOverride: displayedEnergyWh,
340
                onDelete: { checkpoint in
341
                    pendingCheckpointDeletion = checkpoint
342
                }
343
            )
344

            
345
            targetSectionView(
346
                session: session,
347
                predictedPercent: batteryPrediction?.predictedPercent
348
            )
349

            
350
            if showingStopConfirm {
351
                stopConfirmPanel(
352
                    session: session,
Bogdan Timofte authored a month ago
353
                    displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
354
                )
355
            } else {
356
                monitoringActionRow(session)
357
            }
358
        }
359
        .padding(18)
360
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
361
    }
362

            
363
    private func overviewCard(
364
        _ session: ChargeSessionSummary,
365
        chargedDevice: ChargedDeviceSummary
366
    ) -> some View {
Bogdan Timofte authored a month ago
367
        MeterInfoCardView(
368
            title: session.status.isOpen ? "Open Session" : "Overview",
369
            tint: statusTint(for: session),
Bogdan Timofte authored a month ago
370
            isCollapsible: true,
371
            initiallyExpanded: false,
372
            trailingActions: {
373
                HStack(spacing: 4) {
374
                    Text(session.startedAt, style: .time)
375
                        .font(.caption2)
376
                        .foregroundColor(.secondary)
377
                        .monospacedDigit()
378
                    Text("·")
379
                        .font(.caption2)
380
                        .foregroundColor(.secondary)
381
                    Text(sessionDurationText(session))
382
                        .font(.caption2.weight(.semibold))
383
                        .foregroundColor(.secondary)
384
                        .monospacedDigit()
385
                }
386
            }
Bogdan Timofte authored a month ago
387
        ) {
388
            VStack(alignment: .leading, spacing: 10) {
389
                MeterInfoRowView(label: "Device", value: chargedDevice.name)
390

            
391
                Divider()
392

            
393
                HStack(alignment: .top, spacing: 12) {
394
                    overviewStatCell(label: "Started", value: session.startedAt.format())
395
                    if let endedAt = session.endedAt {
396
                        overviewStatCell(label: "Ended", value: endedAt.format())
397
                    }
398
                }
399

            
400
                HStack(alignment: .top, spacing: 12) {
401
                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
402
                    overviewStatCell(label: "Status", value: session.status.title)
403
                }
404

            
405
                Divider()
406

            
407
                HStack(alignment: .top, spacing: 12) {
408
                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
409
                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
410
                }
411

            
412
                HStack(alignment: .top, spacing: 12) {
413
                    overviewStatCell(label: "Source", value: session.sourceMode.title)
414
                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
415
                }
416

            
417
                if session.isTrimmed {
418
                    Divider()
419
                    HStack(alignment: .top, spacing: 12) {
420
                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
421
                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
422
                    }
423
                }
424

            
425
                let meterLabel: String? = session.meterName ?? session.meterMACAddress
426
                if meterLabel != nil || session.meterModel != nil {
427
                    Divider()
428
                    HStack(alignment: .top, spacing: 12) {
429
                        if let label = meterLabel {
430
                            overviewStatCell(label: "Meter", value: label)
431
                        }
432
                        if let model = session.meterModel {
433
                            overviewStatCell(label: "Meter Model", value: model)
434
                        }
435
                    }
436
                }
Bogdan Timofte authored a month ago
437

            
438
                if session.minimumObservedCurrentAmps != nil
439
                    || session.maximumObservedCurrentAmps != nil
440
                    || session.maximumObservedPowerWatts != nil
441
                    || session.maximumObservedVoltageVolts != nil
442
                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
443
                    || session.completionCurrentAmps != nil
444
                    || session.selectedDataGroup != nil {
445

            
446
                    Divider()
447

            
448
                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
449
                        HStack(alignment: .top, spacing: 12) {
450
                            if let v = session.minimumObservedCurrentAmps {
451
                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
452
                            }
453
                            if let v = session.maximumObservedCurrentAmps {
454
                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
455
                            }
456
                        }
457
                    }
458

            
459
                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
460
                        HStack(alignment: .top, spacing: 12) {
461
                            if let v = session.maximumObservedPowerWatts {
462
                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
463
                            }
464
                            if let v = session.maximumObservedVoltageVolts {
465
                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
466
                            }
467
                        }
468
                    }
469

            
470
                    if session.completionCurrentAmps != nil
471
                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
472
                        HStack(alignment: .top, spacing: 12) {
473
                            if let v = session.completionCurrentAmps {
474
                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
475
                            }
476
                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
477
                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
478
                            }
479
                        }
480
                    }
481

            
482
                    if let dg = session.selectedDataGroup {
483
                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
484
                    }
485
                }
Bogdan Timofte authored a month ago
486
            }
487
        }
488
    }
489

            
Bogdan Timofte authored a month ago
490
    private func overviewStatCell(label: String, value: String) -> some View {
491
        VStack(alignment: .leading, spacing: 2) {
492
            Text(label)
493
                .font(.caption2)
494
                .foregroundColor(.secondary)
495
            Text(value)
496
                .font(.footnote.weight(.medium))
497
                .monospacedDigit()
498
        }
499
        .frame(maxWidth: .infinity, alignment: .leading)
500
    }
501

            
Bogdan Timofte authored a month ago
502
    private func batteryCard(
503
        _ session: ChargeSessionSummary,
504
        chargedDevice: ChargedDeviceSummary
505
    ) -> some View {
506
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
507
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
508
            for: session,
509
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
510
        )
511

            
Bogdan Timofte authored a month ago
512
        let startPercent = session.startBatteryPercent
513
        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
514
        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
515
        let showsPreview = startPercent != nil && endPercent != nil
Bogdan Timofte authored a month ago
516

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

            
519
            // Header — always visible, tappable
520
            HStack(spacing: 8) {
521
                Text("Battery")
522
                    .font(.headline)
523
                Spacer(minLength: 0)
524
                Image(systemName: "chevron.up")
525
                    .font(.caption.weight(.semibold))
526
                    .foregroundColor(.secondary)
527
                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
528
                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
529
            }
530
            .contentShape(Rectangle())
531
            .onTapGesture {
532
                withAnimation(.easeInOut(duration: 0.25)) {
533
                    isBatteryCardExpanded.toggle()
Bogdan Timofte authored a month ago
534
                }
Bogdan Timofte authored a month ago
535
            }
Bogdan Timofte authored a month ago
536

            
Bogdan Timofte authored a month ago
537
            // Preview bar — always visible when there is enough data
538
            if showsPreview, let start = startPercent, let end = endPercent {
539
                batteryPreviewBar(
540
                    startPercent: start,
541
                    endPercent: end,
542
                    checkpoints: session.checkpoints,
543
                    isEstimatedEnd: isEstimatedEnd
544
                )
545
                .padding(.top, 10)
546
            }
547

            
548
            // Collapsible detail
549
            if isBatteryCardExpanded {
550
                VStack(alignment: .leading, spacing: 10) {
551

            
552
                    // Energy
Bogdan Timofte authored a month ago
553
                    HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
554
                        overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")
555
                        if let capacityEstimateWh = session.capacityEstimateWh {
556
                            overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
557
                        }
558
                    }
Bogdan Timofte authored a month ago
559
                    if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
560
                        MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
561
                    }
562
                    if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
563
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
564
                        MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
565
                    }
566
                    if let chargerID = session.chargerID,
567
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
568
                        MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
569
                    }
570
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
571
                        Text(wirelessSessionHint)
572
                            .font(.caption2)
573
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
574
                    }
575
                    if let sessionWarning = sessionWarning(for: session) {
576
                        Label(sessionWarning, systemImage: "exclamationmark.triangle")
577
                            .font(.caption2)
578
                            .foregroundColor(.orange)
579
                    }
580

            
581
                    // Battery percentages
582
                    if startPercent != nil || session.endBatteryPercent != nil {
583
                        Divider()
Bogdan Timofte authored a month ago
584
                        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
585
                            if let v = startPercent {
586
                                overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
587
                            }
Bogdan Timofte authored a month ago
588
                            if let v = session.endBatteryPercent {
589
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
590
                            }
591
                        }
Bogdan Timofte authored a month ago
592
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
593
                            HStack(alignment: .top, spacing: 12) {
594
                                if let v = session.batteryDeltaPercent {
595
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
596
                                }
597
                                if let v = session.targetBatteryPercent {
598
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
599
                                }
600
                            }
601
                        }
602
                        if let batteryPrediction {
603
                            HStack(alignment: .top, spacing: 12) {
604
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
605
                            }
606
                            Text(
607
                                "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
608
                            )
609
                            .font(.caption2)
610
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
611
                        }
612
                    }
Bogdan Timofte authored a month ago
613

            
614
                    // Checkpoints
615
                    Divider()
616
                    BatteryCheckpointSectionView(
617
                        sessionID: session.id,
618
                        checkpoints: session.checkpoints,
619
                        message: session.status.isOpen
620
                            ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
621
                            : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
622
                        canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id),
623
                        canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false,
624
                        requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil,
625
                        effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil,
626
                        onDelete: { checkpoint in
627
                            pendingCheckpointDeletion = checkpoint
628
                        }
629
                    )
Bogdan Timofte authored a month ago
630
                }
Bogdan Timofte authored a month ago
631
                .padding(.top, 12)
632
                .transition(.opacity.combined(with: .move(edge: .top)))
633
            }
634
        }
635
        .frame(maxWidth: .infinity, alignment: .leading)
636
        .padding(18)
637
        .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
638
    }
Bogdan Timofte authored a month ago
639

            
Bogdan Timofte authored a month ago
640
    private func batteryPreviewBar(
641
        startPercent: Double,
642
        endPercent: Double,
643
        checkpoints: [ChargeCheckpointSummary],
644
        isEstimatedEnd: Bool
645
    ) -> some View {
646
        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
647
        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
648
        let color = batteryColor(for: endPercent)
649

            
650
        return HStack(spacing: 6) {
651
            Text("\(Int(startPercent.rounded()))%")
652
                .font(.caption2.weight(.semibold))
653
                .foregroundColor(.secondary)
654
                .monospacedDigit()
655
                .frame(minWidth: 26, alignment: .trailing)
656

            
657
            GeometryReader { geo in
658
                let w = geo.size.width
659
                ZStack(alignment: .leading) {
660
                    Capsule()
661
                        .fill(Color.primary.opacity(0.10))
662

            
663
                    Rectangle()
664
                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
665
                        .frame(width: max(w * (endFrac - startFrac), 3))
666
                        .offset(x: w * startFrac)
667

            
668
                    ForEach(checkpoints, id: \.id) { cp in
669
                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
670
                        let isFinal = cp.flag == .final
671
                        Rectangle()
672
                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
673
                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
674
                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
Bogdan Timofte authored a month ago
675
                    }
Bogdan Timofte authored a month ago
676
                }
677
                .clipShape(Capsule())
678
            }
679
            .frame(height: 8)
680

            
681
            HStack(spacing: 1) {
682
                if isEstimatedEnd {
683
                    Text("~")
684
                        .font(.caption2)
685
                        .foregroundColor(.secondary)
686
                }
687
                Text("\(Int(endPercent.rounded()))%")
688
                    .font(.caption2.weight(.semibold))
689
                    .foregroundColor(isEstimatedEnd ? .secondary : color)
690
                    .monospacedDigit()
Bogdan Timofte authored a month ago
691
            }
Bogdan Timofte authored a month ago
692
            .frame(minWidth: 32, alignment: .leading)
Bogdan Timofte authored a month ago
693
        }
694
    }
695

            
696
    private func batteryGaugeSection(
697
        prediction: BatteryLevelPrediction,
698
        session: ChargeSessionSummary,
699
        displayedEnergyWh: Double
700
    ) -> some View {
701
        let percent = prediction.predictedPercent
702
        let color = batteryColor(for: percent)
703
        let duration = displayedSessionDuration(for: session)
704
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
705
            ? displayedEnergyWh / duration
706
            : nil
707
        let etaToFull = etaText(
708
            rateWhPerSec: rateWhPerSec,
709
            remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0),
710
            isRelevant: percent < 98
711
        )
712
        let etaToTarget = etaToTargetText(
713
            session: session,
714
            prediction: prediction,
715
            displayedEnergyWh: displayedEnergyWh,
716
            rateWhPerSec: rateWhPerSec
717
        )
718

            
719
        return VStack(spacing: 10) {
720
            HStack(alignment: .lastTextBaseline, spacing: 8) {
721
                HStack(alignment: .lastTextBaseline, spacing: 3) {
722
                    Text("\(Int(percent.rounded()))")
723
                        .font(.system(size: 52, weight: .bold, design: .rounded))
724
                        .foregroundColor(color)
725
                        .monospacedDigit()
726
                    Text("%")
727
                        .font(.title2.weight(.semibold))
728
                        .foregroundColor(color.opacity(0.8))
729
                }
730

            
731
                Spacer()
732

            
733
                VStack(alignment: .trailing, spacing: 2) {
734
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
735
                        .font(.callout.weight(.bold))
736
                        .foregroundColor(.orange)
737
                        .monospacedDigit()
738
                    Text("est. capacity")
739
                        .font(.caption2)
740
                        .foregroundColor(.secondary)
741
                }
742
            }
743

            
744
            batteryProgressBar(
745
                percent: percent,
746
                startPercent: session.startBatteryPercent,
747
                targetPercent: session.targetBatteryPercent
748
            )
749

            
750
            HStack(spacing: 14) {
751
                if let etaToFull {
752
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
753
                }
754

            
755
                if let etaToTarget, let target = session.targetBatteryPercent {
756
                    etaPill(
757
                        icon: "bell.badge.fill",
758
                        tint: .indigo,
759
                        value: etaToTarget,
760
                        label: "to \(Int(target.rounded()))%"
761
                    )
762
                }
763

            
764
                Spacer()
765

            
766
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
767
                    .font(.caption2)
768
                    .foregroundColor(.secondary)
769
                    .multilineTextAlignment(.trailing)
770
            }
771
        }
772
        .padding(14)
773
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
774
    }
775

            
776
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
777
        VStack(alignment: .leading, spacing: 1) {
778
            HStack(spacing: 4) {
779
                Image(systemName: icon)
780
                    .font(.caption)
781
                    .foregroundColor(tint)
782
                Text(value)
783
                    .font(.caption.weight(.bold))
784
            }
785
            Text(label)
786
                .font(.caption2)
787
                .foregroundColor(.secondary)
788
        }
789
    }
790

            
791
    private func batteryProgressBar(
792
        percent: Double,
793
        startPercent: Double?,
794
        targetPercent: Double?
795
    ) -> some View {
796
        let color = batteryColor(for: percent)
797
        return GeometryReader { geo in
798
            let width = geo.size.width
799
            ZStack(alignment: .leading) {
800
                Capsule()
801
                    .fill(Color.primary.opacity(0.10))
802
                Rectangle()
803
                    .fill(
804
                        LinearGradient(
805
                            colors: [color.opacity(0.6), color],
806
                            startPoint: .leading,
807
                            endPoint: .trailing
808
                        )
809
                    )
810
                    .frame(width: max(width * CGFloat(percent / 100), 4))
811
                    .animation(.easeInOut(duration: 0.4), value: percent)
812
                if let start = startPercent, start > 2, start < 98 {
813
                    Rectangle()
814
                        .fill(Color.white.opacity(0.55))
815
                        .frame(width: 2, height: 20)
816
                        .offset(x: width * CGFloat(start / 100) - 1)
817
                }
818
                if let target = targetPercent {
819
                    Rectangle()
820
                        .fill(Color.indigo.opacity(0.9))
821
                        .frame(width: 2.5, height: 20)
822
                        .offset(x: width * CGFloat(target / 100) - 1.25)
823
                }
824
            }
825
            .clipShape(Capsule())
826
        }
827
        .frame(height: 20)
828
    }
829

            
830
    private func sessionMetricsGrid(
831
        session: ChargeSessionSummary,
832
        chargedDevice: ChargedDeviceSummary,
833
        displayedEnergyWh: Double,
834
        hasPrediction: Bool
835
    ) -> some View {
836
        let capacityFallback: Double? = hasPrediction ? nil : (
837
            session.capacityEstimateWh
838
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
839
                ?? chargedDevice.estimatedBatteryCapacityWh
840
        )
841
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
842

            
843
        return LazyVGrid(columns: columns, spacing: 8) {
844
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
845
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
846

            
847
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
848
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
849
            }
850
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
851
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
852
            }
853

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

            
856
            if let capacityFallback {
857
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
858
            }
859
        }
860
    }
861

            
862
    private func metricCell(label: String, value: String, tint: Color) -> some View {
863
        VStack(alignment: .leading, spacing: 3) {
864
            Text(label)
865
                .font(.caption2)
866
                .foregroundColor(.secondary)
867
            Text(value)
868
                .font(.subheadline.weight(.semibold))
869
                .lineLimit(1)
870
                .minimumScaleFactor(0.7)
871
                .monospacedDigit()
872
        }
873
        .frame(maxWidth: .infinity, alignment: .leading)
874
        .padding(.horizontal, 12)
875
        .padding(.vertical, 10)
876
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
877
    }
878

            
879
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
880
        VStack(alignment: .leading, spacing: 10) {
881
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
882
                .font(.subheadline.weight(.semibold))
883

            
884
            if let contradictionPercent = session.completionContradictionPercent {
885
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
886
                    .font(.caption)
887
                    .foregroundColor(.secondary)
888
            } else {
889
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
890
                    .font(.caption)
891
                    .foregroundColor(.secondary)
892
            }
893

            
894
            HStack(spacing: 10) {
895
                Button("Finish") {
896
                    beginStopConfirmation(for: session)
897
                }
898
                .frame(maxWidth: .infinity)
899
                .padding(.vertical, 9)
900
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
901
                .buttonStyle(.plain)
902

            
903
                Button("Keep Monitoring") {
904
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
905
                }
906
                .frame(maxWidth: .infinity)
907
                .padding(.vertical, 9)
908
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
909
                .buttonStyle(.plain)
910
            }
911
        }
912
        .padding(14)
913
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
914
    }
915

            
916
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
917
        let draftBelowPrediction: Bool = {
918
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
919
            return draft <= predictedPercent
920
        }()
921
        let savedBelowPrediction: Bool = {
922
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
923
            return saved <= predictedPercent
924
        }()
925

            
926
        return HStack(alignment: .center, spacing: 8) {
927
            Image(systemName: "bell.badge")
928
                .foregroundColor(.indigo)
929
                .font(.subheadline)
930

            
931
            Text("Notify at")
932
                .font(.subheadline.weight(.semibold))
933

            
934
            Spacer(minLength: 8)
935

            
936
            if showingInlineTargetEditor {
937
                targetEditorControls(
938
                    session: session,
939
                    draftBelowPrediction: draftBelowPrediction,
940
                    predictedPercent: predictedPercent
941
                )
942
            } else {
943
                savedTargetControls(
944
                    session: session,
945
                    savedBelowPrediction: savedBelowPrediction,
946
                    predictedPercent: predictedPercent
947
                )
948
            }
949
        }
950
    }
951

            
952
    private func targetEditorControls(
953
        session: ChargeSessionSummary,
954
        draftBelowPrediction: Bool,
955
        predictedPercent: Double?
956
    ) -> some View {
957
        Group {
958
            Button {
959
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
960
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
961
            } label: {
962
                Image(systemName: "minus.circle")
963
                    .font(.title3)
964
            }
965
            .buttonStyle(.plain)
966

            
967
            TextField("-", text: $draftTargetText)
968
                .keyboardType(.decimalPad)
969
                .textFieldStyle(.roundedBorder)
970
                .frame(width: 48)
971
                .multilineTextAlignment(.center)
972
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
973

            
974
            Text("%")
975
                .font(.subheadline)
976
                .foregroundColor(.secondary)
977

            
978
            if draftBelowPrediction, let predictedPercent {
979
                predictionWarningButton(predictedPercent: predictedPercent)
980
            }
981

            
982
            Button {
983
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
984
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
985
            } label: {
986
                Image(systemName: "plus.circle")
987
                    .font(.title3)
988
            }
989
            .buttonStyle(.plain)
990

            
991
            Button {
992
                if let value = parsedDraftTarget {
993
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
994
                }
995
                showingInlineTargetEditor = false
996
            } label: {
997
                Image(systemName: "checkmark.circle.fill")
998
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
999
                    .font(.title3)
1000
            }
1001
            .buttonStyle(.plain)
1002
            .disabled(parsedDraftTarget == nil)
1003

            
1004
            Button {
1005
                showingInlineTargetEditor = false
1006
                draftTargetText = ""
1007
            } label: {
1008
                Image(systemName: "xmark.circle")
1009
                    .foregroundColor(.secondary)
1010
                    .font(.title3)
1011
            }
1012
            .buttonStyle(.plain)
1013
        }
1014
    }
1015

            
1016
    private func savedTargetControls(
1017
        session: ChargeSessionSummary,
1018
        savedBelowPrediction: Bool,
1019
        predictedPercent: Double?
1020
    ) -> some View {
1021
        Group {
1022
            if let targetPercent = session.targetBatteryPercent {
1023
                Text("\(targetPercent.format(decimalDigits: 0))%")
1024
                    .font(.subheadline.weight(.semibold))
1025
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1026

            
1027
                if savedBelowPrediction, let predictedPercent {
1028
                    predictionWarningButton(predictedPercent: predictedPercent)
1029
                }
1030

            
1031
                Button {
1032
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
1033
                } label: {
1034
                    Image(systemName: "xmark.circle.fill")
1035
                        .foregroundColor(.secondary)
1036
                        .font(.callout)
1037
                }
1038
                .buttonStyle(.plain)
1039
                .help("Remove alert")
1040
            }
1041

            
1042
            Button {
1043
                draftTargetText = session.targetBatteryPercent.map {
1044
                    $0.format(decimalDigits: 0)
1045
                } ?? "80"
1046
                showingInlineTargetEditor = true
1047
            } label: {
1048
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1049
                    .font(.caption.weight(.semibold))
1050
                    .frame(width: 30, height: 30)
1051
                    .contentShape(Rectangle())
1052
            }
1053
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1054
            .buttonStyle(.plain)
1055
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1056
        }
1057
    }
1058

            
1059
    private func predictionWarningButton(predictedPercent: Double) -> some View {
1060
        Button {} label: {
1061
            Image(systemName: "exclamationmark.triangle.fill")
1062
                .font(.callout.weight(.semibold))
1063
                .foregroundColor(.orange)
1064
        }
1065
        .buttonStyle(.plain)
1066
        .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.")
1067
    }
1068

            
1069
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
1070
        HStack(spacing: 10) {
1071
            if session.status == .active {
1072
                Button("Pause") {
1073
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1074
                }
1075
                .monitoringActionStyle(tint: .orange)
1076
            } else if session.status == .paused {
1077
                Button("Resume") {
1078
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1079
                }
1080
                .monitoringActionStyle(tint: .blue)
1081
            }
1082

            
1083
            Button("Terminate Session") {
1084
                beginStopConfirmation(for: session)
1085
            }
1086
            .monitoringActionStyle(tint: .red)
1087
        }
1088
    }
1089

            
1090
    private func stopConfirmPanel(
1091
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1092
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1093
    ) -> some View {
1094
        let canSave = hasSavableChargeData(
1095
            session: session,
Bogdan Timofte authored a month ago
1096
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1097
        )
1098
        let saveDisabledReason = saveDisabledReason(
1099
            session: session,
Bogdan Timofte authored a month ago
1100
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1101
        )
1102
        let isSaveEnabled = saveDisabledReason == nil
1103

            
1104
        return VStack(alignment: .leading, spacing: 12) {
1105
            HStack {
1106
                Text("Final Checkpoint")
1107
                    .font(.subheadline.weight(.semibold))
1108
                Text("optional")
1109
                    .font(.caption2.weight(.semibold))
1110
                    .foregroundColor(.secondary)
1111
            }
1112

            
1113
            finalCheckpointPicker(session)
1114

            
1115
            if finalCheckpointMode == .custom {
1116
                customFinalCheckpointRow
1117
            }
1118

            
1119
            if let saveDisabledReason {
1120
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
1121
                    .font(.caption)
1122
                    .foregroundColor(.red)
1123
                    .fixedSize(horizontal: false, vertical: true)
1124
            } else if let stopFailureMessage {
1125
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
1126
                    .font(.caption)
1127
                    .foregroundColor(.red)
1128
                    .fixedSize(horizontal: false, vertical: true)
1129
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
1130
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
1131
                    .font(.caption)
1132
                    .foregroundColor(.green)
1133
                    .fixedSize(horizontal: false, vertical: true)
1134
            }
1135

            
1136
            HStack(spacing: 8) {
1137
                Button("Discard") {
1138
                    discardSession(session)
1139
                }
1140
                .monitoringPanelActionStyle(tint: .secondary)
1141

            
1142
                Button {
1143
                    stopSession(
1144
                        session,
Bogdan Timofte authored a month ago
1145
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1146
                    )
1147
                } label: {
1148
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1149
                        .frame(maxWidth: .infinity)
1150
                }
1151
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1152
                .disabled(!isSaveEnabled)
1153
                .help(saveDisabledReason ?? "Close and save this session")
1154

            
1155
                Button("Cancel") {
1156
                    resetStopConfirmation()
1157
                }
1158
                .monitoringPanelActionStyle(tint: .secondary)
1159
            }
1160
        }
1161
        .padding(14)
1162
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1163
    }
1164

            
1165
    private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
1166
        return HStack(spacing: 8) {
1167
            ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1168
                Button {
1169
                    finalCheckpointMode = mode
1170
                    if mode == .custom {
1171
                        prefillFinalCheckpointIfNeeded(for: session)
1172
                    } else {
1173
                        finalCheckpointText = ""
1174
                    }
1175
                } label: {
1176
                    VStack(spacing: 5) {
1177
                        Image(systemName: mode.icon)
1178
                            .font(.title3)
1179
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1180
                        Text(mode.label)
1181
                            .font(.caption.weight(.semibold))
1182
                            .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1183
                    }
1184
                    .frame(maxWidth: .infinity)
1185
                    .padding(.vertical, 10)
1186
                    .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
1187
                    .meterCard(
1188
                        tint: finalCheckpointMode == mode ? .primary : .secondary,
1189
                        fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1190
                        strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1191
                        cornerRadius: 12
1192
                    )
1193
                }
1194
                .buttonStyle(.plain)
1195
            }
1196
        }
1197
    }
1198

            
1199
    private var customFinalCheckpointRow: some View {
1200
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1201
            || parsedFinalCheckpoint == nil
1202

            
1203
        return HStack(spacing: 8) {
1204
            Button {
1205
                adjustFinalCheckpoint(by: -1)
1206
            } label: {
1207
                Image(systemName: "minus.circle").font(.title3)
1208
            }
1209
            .buttonStyle(.plain)
1210

            
1211
            TextField("-", text: $finalCheckpointText)
1212
                .keyboardType(.decimalPad)
1213
                .textFieldStyle(.roundedBorder)
1214
                .frame(width: 56)
1215
                .multilineTextAlignment(.center)
1216
                .overlay(
1217
                    RoundedRectangle(cornerRadius: 6)
1218
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1219
                )
1220

            
1221
            Text("%").foregroundColor(.secondary)
1222

            
1223
            Text("required")
1224
                .font(.caption2.weight(.semibold))
1225
                .foregroundColor(isInvalid ? .red : .secondary)
1226

            
1227
            Button {
1228
                adjustFinalCheckpoint(by: 1)
1229
            } label: {
1230
                Image(systemName: "plus.circle").font(.title3)
1231
            }
1232
            .buttonStyle(.plain)
1233

            
1234
            Spacer()
1235
        }
1236
    }
1237

            
1238
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1239
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1240
            if let meterName = session.meterName {
1241
                MeterInfoRowView(label: "Controlled On", value: meterName)
1242
            }
1243
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1244
                .font(.caption2)
1245
                .foregroundColor(.secondary)
1246
        }
1247
    }
1248

            
1249
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1250
        MeterInfoCardView(title: "Administration", tint: .red) {
1251
            Button(role: .destructive) {
1252
                pendingSessionDeletion = session
1253
            } label: {
1254
                Label("Delete Session", systemImage: "trash")
1255
                    .font(.subheadline.weight(.semibold))
1256
                    .frame(maxWidth: .infinity)
1257
                    .padding(.vertical, 10)
1258
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1259
            }
1260
            .buttonStyle(.plain)
1261
        }
1262
    }
1263

            
1264
    @ViewBuilder
1265
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1266
        if let window = detectedTrimWindow {
1267
            HStack(spacing: 12) {
1268
                Image(systemName: "scissors.circle.fill")
1269
                    .font(.title3)
1270
                    .foregroundColor(.blue)
1271

            
1272
                VStack(alignment: .leading, spacing: 2) {
1273
                    Text("Charging ended early")
1274
                        .font(.subheadline.weight(.semibold))
1275
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1276
                        .font(.caption)
1277
                        .foregroundColor(.secondary)
1278
                        .fixedSize(horizontal: false, vertical: true)
1279
                }
1280

            
1281
                Spacer(minLength: 0)
1282

            
1283
                VStack(spacing: 6) {
1284
                    Button("Trim Start") {
1285
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1286
                        trimBannerDismissedForSessionID = session.id
1287
                    }
1288
                    .font(.caption.weight(.semibold))
1289
                    .buttonStyle(.borderedProminent)
1290
                    .controlSize(.small)
1291
                    .tint(.blue)
1292

            
1293
                    Button("End & Finish") {
1294
                        requestStop(
1295
                            session,
1296
                            applyingTrimStart: session.trimStart ?? window.start,
1297
                            trimEnd: window.end,
1298
                            title: "Trim End & Finish",
1299
                            confirmTitle: "Finish",
1300
                            explanation: "The detected charging window will be saved before the session is closed."
1301
                        )
1302
                        trimBannerDismissedForSessionID = session.id
1303
                    }
1304
                    .font(.caption.weight(.semibold))
1305
                    .buttonStyle(.bordered)
1306
                    .controlSize(.small)
1307
                    .tint(.red)
1308
                }
1309
            }
1310
            .padding(14)
1311
            .background(
1312
                RoundedRectangle(cornerRadius: 14)
1313
                    .fill(Color.blue.opacity(0.10))
1314
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1315
            )
1316
            .transition(.opacity.combined(with: .move(edge: .top)))
1317
        }
1318
    }
1319

            
1320
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1321
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1322
    }
1323

            
1324
    private func chartCard(_ session: ChargeSessionSummary) -> some View {
1325
        ChargeSessionChartCardView(
1326
            session: session,
1327
            monitoringMeter: liveMonitoringMeter,
1328
            controlMode: chartControlMode(for: session),
1329
            onSetTrim: { start, end in
1330
                setSessionTrim(sessionID: session.id, start: start, end: end)
1331
            },
1332
            onStopWithTrim: { start, end in
1333
                requestStop(
1334
                    session,
1335
                    applyingTrimStart: start,
1336
                    trimEnd: end,
1337
                    title: "Trim End & Finish",
1338
                    confirmTitle: "Finish",
1339
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1340
                )
Bogdan Timofte authored a month ago
1341
            },
1342
            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1343
                ? {
1344
                    pendingTrimCommitSession = session
1345
                }
1346
                : nil
Bogdan Timofte authored a month ago
1347
        )
1348
    }
1349

            
1350
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1351
        if hasMonitoringControls {
1352
            return .activeMonitoring
1353
        }
1354

            
1355
        if session.status.isOpen == false {
1356
            return .closed
1357
        }
1358

            
1359
        return .none
1360
    }
1361

            
1362
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1363
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1364
        trimBannerDismissedForSessionID = sessionID
1365
    }
1366

            
1367
    private func requestStop(
1368
        _ session: ChargeSessionSummary,
1369
        applyingTrimStart trimStart: Date?,
1370
        trimEnd: Date?,
1371
        title: String,
1372
        confirmTitle: String,
1373
        explanation: String
1374
    ) {
1375
        pendingSessionStopRequest = ChargeSessionStopRequest(
1376
            sessionID: session.id,
1377
            title: title,
1378
            confirmTitle: confirmTitle,
1379
            explanation: explanation,
1380
            appliesTrim: trimStart != nil || trimEnd != nil,
1381
            trimStart: trimStart,
1382
            trimEnd: trimEnd
1383
        )
1384
    }
1385

            
1386
    private var parsedDraftTarget: Double? {
1387
        let normalized = draftTargetText
1388
            .trimmingCharacters(in: .whitespacesAndNewlines)
1389
            .replacingOccurrences(of: ",", with: ".")
1390
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1391
        return value
1392
    }
1393

            
1394
    private var parsedFinalCheckpoint: Double? {
1395
        let normalized = finalCheckpointText
1396
            .trimmingCharacters(in: .whitespacesAndNewlines)
1397
            .replacingOccurrences(of: ",", with: ".")
1398
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1399
        return value
1400
    }
1401

            
1402
    private var resolvedFinalCheckpoint: Double? {
1403
        switch finalCheckpointMode {
1404
        case .full:   return 100
1405
        case .skip:   return nil
1406
        case .custom: return parsedFinalCheckpoint
1407
        }
1408
    }
1409

            
1410
    private func adjustFinalCheckpoint(by delta: Double) {
1411
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1412
        let next = min(max(current + delta, 0), 100)
1413
        finalCheckpointText = next.format(decimalDigits: 0)
1414
    }
1415

            
1416
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1417
        guard let session else { return nil }
1418
        if let endBatteryPercent = session.endBatteryPercent {
1419
            return endBatteryPercent
1420
        }
1421
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1422
            return latestCheckpoint.batteryPercent
1423
        }
1424
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1425
    }
1426

            
1427
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1428
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1429
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1430
            return
1431
        }
1432
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1433
    }
1434

            
1435
    private func hasSavableChargeData(
1436
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1437
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1438
    ) -> Bool {
1439
        session.hasSavableChargeData
1440
            || displayedEnergyWh > 0
1441
    }
1442

            
1443
    private func saveDisabledReason(
1444
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1445
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1446
    ) -> String? {
1447
        if finalCheckpointMode == .custom {
1448
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1449
            if trimmed.isEmpty {
1450
                return "Enter the final battery percentage or choose Skip."
1451
            }
1452
            if parsedFinalCheckpoint == nil {
1453
                return "Final battery percentage must be between 0 and 100."
1454
            }
1455
        }
1456

            
1457
        guard hasSavableChargeData(
1458
            session: session,
Bogdan Timofte authored a month ago
1459
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1460
        ) else {
1461
            return "This session has no charging data to save. Discard it instead."
1462
        }
1463

            
1464
        return nil
1465
    }
1466

            
1467
    private func stopSession(
1468
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1469
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1470
    ) {
1471
        stopFailureMessage = nil
1472

            
1473
        if let saveDisabledReason = saveDisabledReason(
1474
            session: session,
Bogdan Timofte authored a month ago
1475
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1476
        ) {
1477
            stopFailureMessage = saveDisabledReason
1478
            return
1479
        }
1480

            
1481
        let didSave = appData.stopChargeSession(
1482
            sessionID: session.id,
1483
            finalBatteryPercent: resolvedFinalCheckpoint,
1484
            from: liveMonitoringMeter
1485
        )
1486
        if didSave {
1487
            resetStopConfirmation()
1488
        } else {
1489
            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."
1490
        }
1491
    }
1492

            
1493
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1494
        finalCheckpointMode = .skip
1495
        finalCheckpointText = ""
1496
        stopFailureMessage = nil
1497
        showingStopConfirm = true
1498
    }
1499

            
1500
    private func discardSession(_ session: ChargeSessionSummary) {
1501
        _ = appData.deleteChargeSession(sessionID: session.id)
1502
        resetStopConfirmation()
1503
    }
1504

            
1505
    private func resetStopConfirmation() {
1506
        showingStopConfirm = false
1507
        finalCheckpointText = ""
1508
        finalCheckpointMode = .skip
1509
        stopFailureMessage = nil
1510
    }
1511

            
1512
    private func syncMonitoringRestore() {
1513
        guard let session,
1514
              session.status.isOpen,
1515
              let liveMonitoringMeter,
1516
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1517
            return
1518
        }
1519
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1520
    }
1521

            
1522
    private func runTrimDetection() {
1523
        guard hasMonitoringControls,
1524
              let session,
1525
              session.isTrimmed == false,
1526
              !session.aggregatedSamples.isEmpty else {
1527
            detectedTrimWindow = nil
1528
            return
1529
        }
1530

            
1531
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1532
        detectedTrimWindow = ChargingWindowDetector.detect(
1533
            samples: session.aggregatedSamples,
1534
            sessionStart: session.startedAt,
1535
            sessionEnd: sessionEnd
1536
        )
1537
    }
1538

            
1539
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1540
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1541
        guard session.isTrimmed == false else { return storedEnergyWh }
1542
        guard session.status.isOpen else { return storedEnergyWh }
1543
        guard let liveMonitoringMeter else { return storedEnergyWh }
1544
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1545
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1546
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1547
        }
1548
        return storedEnergyWh
1549
    }
1550

            
1551
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1552
        let storedDuration = max(session.effectiveDuration, 0)
1553
        guard session.isTrimmed == false else { return storedDuration }
1554
        guard session.status.isOpen else { return storedDuration }
1555
        guard let liveMonitoringMeter else { return storedDuration }
1556
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1557
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1558
    }
1559

            
1560
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1561
        let displayedDuration = displayedSessionDuration(for: session)
1562
        let formatter = DateComponentsFormatter()
1563
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1564
        formatter.unitsStyle = .abbreviated
1565
        formatter.zeroFormattingBehavior = .dropAll
1566
        return formatter.string(from: displayedDuration) ?? "0m"
1567
    }
1568

            
1569
    private func formatDuration(_ duration: TimeInterval) -> String {
1570
        let totalSeconds = Int(duration.rounded(.down))
1571
        let hours = totalSeconds / 3600
1572
        let minutes = (totalSeconds % 3600) / 60
1573
        let seconds = totalSeconds % 60
1574
        if hours > 0 {
1575
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1576
        }
1577
        return String(format: "%02d:%02d", minutes, seconds)
1578
    }
1579

            
1580
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1581
        if session.autoStopEnabled == false {
1582
            return "Manual"
1583
        }
1584

            
1585
        if let sessionWarning = sessionWarning(for: session),
1586
           sessionWarning.contains("idle-current") {
1587
            return "Blocked by charger setup"
1588
        }
1589

            
1590
        if session.stopThresholdAmps > 0 {
1591
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1592
        }
1593

            
1594
        return "Learning"
1595
    }
1596

            
1597
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1598
        if session.autoStopEnabled == false {
1599
            return "Manual"
1600
        }
1601
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1602
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1603
        }
1604
        if session.stopThresholdAmps > 0 {
1605
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1606
        }
1607
        return "Learning"
1608
    }
1609

            
1610
    private func shouldShowChargingTransport(
1611
        for session: ChargeSessionSummary,
1612
        chargedDevice: ChargedDeviceSummary
1613
    ) -> Bool {
1614
        chargedDevice.supportedChargingModes.count > 1
1615
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1616
    }
1617

            
1618
    private func shouldShowChargingState(
1619
        for session: ChargeSessionSummary,
1620
        chargedDevice: ChargedDeviceSummary
1621
    ) -> Bool {
1622
        chargedDevice.supportedChargingStateModes.count > 1
1623
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1624
    }
1625

            
1626
    private func batteryColor(for percent: Double) -> Color {
1627
        if percent >= 75 { return .green }
1628
        if percent >= 35 { return .orange }
1629
        return .red
1630
    }
1631

            
1632
    private func etaText(
1633
        rateWhPerSec: Double?,
1634
        remainingWh: Double,
1635
        isRelevant: Bool
1636
    ) -> String? {
1637
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1638
        let seconds = remainingWh / rateWhPerSec
1639
        return seconds > 120 ? formatETA(seconds) : nil
1640
    }
1641

            
1642
    private func etaToTargetText(
1643
        session: ChargeSessionSummary,
1644
        prediction: BatteryLevelPrediction,
1645
        displayedEnergyWh: Double,
1646
        rateWhPerSec: Double?
1647
    ) -> String? {
1648
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1649
            return nil
1650
        }
1651
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1652
        return etaText(
1653
            rateWhPerSec: rateWhPerSec,
1654
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1655
            isRelevant: true
1656
        )
1657
    }
1658

            
1659
    private func formatETA(_ seconds: TimeInterval) -> String {
1660
        let totalMinutes = Int(seconds / 60)
1661
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1662
        let hours = totalMinutes / 60
1663
        let minutes = totalMinutes % 60
1664
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1665
    }
1666

            
1667
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1668
        switch session.status {
1669
        case .active:
1670
            return .red
1671
        case .paused:
1672
            return .orange
1673
        case .completed:
1674
            return .green
1675
        case .abandoned:
1676
            return .secondary
1677
        }
1678
    }
1679

            
1680
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1681
        nil
Bogdan Timofte authored a month ago
1682
    }
1683

            
1684
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1685
        guard session.chargingTransportMode == .wireless else {
1686
            return nil
1687
        }
1688

            
1689
        var components: [String] = []
1690
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1691
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1692
        }
1693
        if session.usesEstimatedWirelessEfficiency {
1694
            components.append("Estimated from wired baseline and checkpoints")
1695
        }
1696
        if session.shouldWarnAboutLowWirelessEfficiency {
1697
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1698
        }
1699

            
1700
        return components.isEmpty ? nil : components.joined(separator: " - ")
1701
    }
1702

            
1703
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1704
        switch session.status {
1705
        case .active:
1706
            return .green
1707
        case .paused:
1708
            return .orange
1709
        case .completed:
1710
            return .teal
1711
        case .abandoned:
1712
            return .secondary
1713
        }
1714
    }
1715
}
1716

            
1717
enum ChargeSessionChartControlMode {
1718
    case none
1719
    case activeMonitoring
1720
    case closed
1721
}
1722

            
1723
struct ChargeSessionChartCardView: View {
1724
    let session: ChargeSessionSummary
1725
    let monitoringMeter: Meter?
1726
    let controlMode: ChargeSessionChartControlMode
1727
    let onSetTrim: (Date?, Date?) -> Void
1728
    let onStopWithTrim: (Date?, Date?) -> Void
Bogdan Timofte authored a month ago
1729
    let onCommitTrim: (() -> Void)?
Bogdan Timofte authored a month ago
1730

            
1731
    @StateObject private var storedMeasurements = Measurements()
1732

            
1733
    private var chartMeasurements: Measurements {
1734
        if let monitoringMeter,
1735
           session.status.isOpen,
1736
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1737
            return monitoringMeter.chargeRecordMeasurements
1738
        }
1739
        return storedMeasurements
1740
    }
1741

            
1742
    private var fullTimeRange: ClosedRange<Date> {
1743
        let start = session.startedAt
1744
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1745
        return start...end
1746
    }
1747

            
1748
    private var fixedTimeRange: ClosedRange<Date>? {
1749
        if monitoringMeter != nil && session.status.isOpen {
1750
            return nil
1751
        }
1752
        return session.effectiveTimeRange
1753
    }
1754

            
1755
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1756
        guard monitoringMeter != nil && session.status.isOpen else {
1757
            return (nil, nil)
1758
        }
1759
        return (session.trimStart, session.trimEnd)
1760
    }
1761

            
1762
    private var showsRangeSelector: Bool {
1763
        controlMode != .none && !session.aggregatedSamples.isEmpty
1764
    }
1765

            
1766
    var body: some View {
1767
        VStack(alignment: .leading, spacing: 12) {
1768
            HStack(spacing: 8) {
1769
                Image(systemName: "chart.xyaxis.line")
1770
                    .foregroundColor(.blue)
1771
                Text("Session Chart")
1772
                    .font(.headline)
1773
                ContextInfoButton(
1774
                    title: "Session Chart",
1775
                    message: chartInfoMessage
1776
                )
1777
                Spacer(minLength: 0)
1778
            }
1779

            
1780
            MeasurementChartView(
1781
                timeRange: fixedTimeRange,
1782
                timeRangeLowerBound: liveTrimBounds.lower,
1783
                timeRangeUpperBound: liveTrimBounds.upper,
1784
                showsRangeSelector: showsRangeSelector,
1785
                rebasesEnergyToVisibleRangeStart: true,
1786
                extendsTimelineToPresent: false,
1787
                showsTemperatureSeries: false,
1788
                rangeSelectorConfiguration: rangeSelectorConfiguration
1789
            )
1790
            .environmentObject(chartMeasurements)
1791
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
1792

            
1793
            if let onCommitTrim {
1794
                Divider()
1795

            
1796
                HStack(alignment: .center, spacing: 10) {
1797
                    Label("Save trim permanently", systemImage: "internaldrive")
1798
                        .font(.caption.weight(.semibold))
1799
                        .foregroundColor(.secondary)
1800

            
1801
                    Spacer(minLength: 0)
1802

            
1803
                    Button {
1804
                        onCommitTrim()
1805
                    } label: {
1806
                        Label("Save Trim", systemImage: "checkmark.seal")
1807
                            .font(.caption.weight(.semibold))
1808
                    }
1809
                    .buttonStyle(.borderedProminent)
1810
                    .controlSize(.small)
1811
                    .tint(.red)
1812
                }
1813
            }
Bogdan Timofte authored a month ago
1814
        }
1815
        .padding(18)
1816
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1817
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1818
        .onChange(of: session.id) { _ in
1819
            restoreStoredMeasurementsIfNeeded()
1820
        }
1821
        .onChange(of: session.aggregatedSamples.count) { _ in
1822
            restoreStoredMeasurementsIfNeeded()
1823
        }
1824
    }
1825

            
1826
    private var chartInfoMessage: String {
1827
        if monitoringMeter != nil && session.status.isOpen {
1828
            return "This chart combines the persisted session curve with current live data from this meter."
1829
        }
1830

            
1831
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1832
    }
1833

            
1834
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
1835
        switch controlMode {
1836
        case .none:
1837
            return nil
1838
        case .activeMonitoring:
1839
            return MeasurementChartRangeSelectorConfiguration(
1840
                keepAction: MeasurementChartSelectionAction(
1841
                    title: "Trim Start",
1842
                    shortTitle: "Start",
1843
                    systemName: "arrow.right.to.line",
1844
                    tone: .destructive,
1845
                    handler: applyActiveStartTrim
1846
                ),
1847
                removeAction: MeasurementChartSelectionAction(
1848
                    title: "Trim End & Finish",
1849
                    shortTitle: "End",
1850
                    systemName: "arrow.left.to.line",
1851
                    tone: .destructiveProminent,
1852
                    handler: requestActiveEndTrim
1853
                ),
1854
                resetAction: MeasurementChartResetAction(
1855
                    title: "Reset Trim",
1856
                    shortTitle: "Reset",
1857
                    systemName: "arrow.counterclockwise",
1858
                    tone: .reversible,
1859
                    confirmationTitle: "Reset session trim?",
1860
                    confirmationButtonTitle: "Reset trim",
1861
                    handler: {
1862
                        onSetTrim(nil, nil)
1863
                    }
1864
                )
1865
            )
1866
        case .closed:
1867
            return MeasurementChartRangeSelectorConfiguration(
1868
                keepAction: MeasurementChartSelectionAction(
1869
                    title: "Trim Window",
1870
                    shortTitle: "Trim",
1871
                    systemName: "scissors",
1872
                    tone: .destructive,
1873
                    handler: applyClosedTrim
1874
                ),
1875
                removeAction: nil,
1876
                resetAction: MeasurementChartResetAction(
1877
                    title: "Reset Trim",
1878
                    shortTitle: "Reset",
1879
                    systemName: "arrow.counterclockwise",
1880
                    tone: .reversible,
1881
                    confirmationTitle: "Reset session trim?",
1882
                    confirmationButtonTitle: "Reset trim",
1883
                    handler: {
1884
                        onSetTrim(nil, nil)
1885
                    }
1886
                )
1887
            )
1888
        }
1889
    }
1890

            
1891
    private func restoreStoredMeasurementsIfNeeded() {
1892
        guard monitoringMeter == nil || session.status.isOpen == false else {
1893
            return
1894
        }
1895
        storedMeasurements.resetSeries()
1896
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
1897
            from: session,
1898
            replacingLiveBufferIfNeeded: true
1899
        )
1900
    }
1901

            
1902
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
1903
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
1904
    }
1905

            
1906
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
1907
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
1908
        let end = normalizedEnd(range.upperBound)
1909
        onStopWithTrim(start, end)
1910
    }
1911

            
1912
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
1913
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
1914
    }
1915

            
1916
    private func normalizedStart(_ date: Date) -> Date? {
1917
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
1918
    }
1919

            
1920
    private func normalizedEnd(_ date: Date) -> Date? {
1921
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
1922
    }
1923
}
1924

            
1925
private struct ChargeSessionStopRequest: Identifiable {
1926
    let sessionID: UUID
1927
    let title: String
1928
    let confirmTitle: String
1929
    let explanation: String
1930
    let appliesTrim: Bool
1931
    let trimStart: Date?
1932
    let trimEnd: Date?
1933

            
1934
    var id: String {
1935
        [
1936
            sessionID.uuidString,
1937
            title,
1938
            trimStart?.timeIntervalSince1970.description ?? "nil",
1939
            trimEnd?.timeIntervalSince1970.description ?? "nil"
1940
        ].joined(separator: "-")
1941
    }
1942
}
1943

            
1944
private extension View {
1945
    func monitoringActionStyle(tint: Color) -> some View {
1946
        frame(maxWidth: .infinity)
1947
            .padding(.vertical, 10)
1948
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1949
            .buttonStyle(.plain)
1950
    }
1951

            
1952
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
1953
        frame(maxWidth: .infinity)
1954
            .padding(.vertical, 9)
1955
            .meterCard(
1956
                tint: tint,
1957
                fillOpacity: isProminent ? 0.22 : 0.10,
1958
                strokeOpacity: isProminent ? 0.32 : 0.14,
1959
                cornerRadius: 14
1960
            )
1961
            .buttonStyle(.plain)
1962
    }
1963
}