Newer Older
1965 lines | 77.712kb
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
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
237
        .toolbar {
238
            ToolbarItemGroup(placement: .primaryAction) {
239
                if session.status.isOpen == false {
240
                    Button(role: .destructive) {
241
                        pendingSessionDeletion = session
242
                    } label: {
243
                        Image(systemName: "trash")
244
                    }
245
                    .help("Delete session")
246
                }
247
            }
248
        }
Bogdan Timofte authored a month ago
249
    }
250

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

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

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

            
284
                Spacer()
285

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

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

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

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

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

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

            
330
            if session.requiresCompletionConfirmation && !showingStopConfirm {
331
                completionConfirmationCard(session)
332
            }
333

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

            
347
            targetSectionView(
348
                session: session,
349
                predictedPercent: batteryPrediction?.predictedPercent
350
            )
351

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

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

            
393
                Divider()
394

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

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

            
407
                Divider()
408

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

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

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

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

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

            
448
                    Divider()
449

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
733
                Spacer()
734

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

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

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

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

            
766
                Spacer()
767

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
933
            Text("Notify at")
934
                .font(.subheadline.weight(.semibold))
935

            
936
            Spacer(minLength: 8)
937

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

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

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

            
976
            Text("%")
977
                .font(.subheadline)
978
                .foregroundColor(.secondary)
979

            
980
            if draftBelowPrediction, let predictedPercent {
981
                predictionWarningButton(predictedPercent: predictedPercent)
982
            }
983

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

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

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

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

            
1029
                if savedBelowPrediction, let predictedPercent {
1030
                    predictionWarningButton(predictedPercent: predictedPercent)
1031
                }
1032

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

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

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

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

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

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

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

            
1115
            finalCheckpointPicker(session)
1116

            
1117
            if finalCheckpointMode == .custom {
1118
                customFinalCheckpointRow
1119
            }
1120

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

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

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

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

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

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

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

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

            
1223
            Text("%").foregroundColor(.secondary)
1224

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

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

            
1236
            Spacer()
1237
        }
1238
    }
1239

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

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

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

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

            
1283
                Spacer(minLength: 0)
1284

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

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

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

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

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

            
1357
        if session.status.isOpen == false {
1358
            return .closed
1359
        }
1360

            
1361
        return .none
1362
    }
1363

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

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

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

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

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

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

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

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

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

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

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

            
1466
        return nil
1467
    }
1468

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1596
        return "Learning"
1597
    }
1598

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

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

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

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

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

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

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

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

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

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

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

            
1702
        return components.isEmpty ? nil : components.joined(separator: " - ")
1703
    }
1704

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

            
1719
enum ChargeSessionChartControlMode {
1720
    case none
1721
    case activeMonitoring
1722
    case closed
1723
}
1724

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

            
1733
    @StateObject private var storedMeasurements = Measurements()
1734

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

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

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

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

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

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

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

            
1795
            if let onCommitTrim {
1796
                Divider()
1797

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

            
1803
                    Spacer(minLength: 0)
1804

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

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

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

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

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

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

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

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

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

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

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

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

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

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