Newer Older
2234 lines | 88.032kb
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

            
Bogdan Timofte authored a month ago
38
    private struct BatteryPercentCandidate {
39
        let timestamp: Date
40
        let percent: Double
41
        let isCheckpoint: Bool
42
    }
43

            
Bogdan Timofte authored a month ago
44
    @EnvironmentObject private var appData: AppData
45

            
46
    let chargedDeviceID: UUID
47
    let sessionID: UUID
48
    let monitoringMeter: Meter?
49
    let presentation: ChargeSessionDetailPresentation
50

            
51
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
52
    @State private var pendingSessionDeletion: ChargeSessionSummary?
53
    @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
Bogdan Timofte authored a month ago
54
    @State private var pendingTrimCommitSession: ChargeSessionSummary?
Bogdan Timofte authored a month ago
55
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
56
    @State private var trimBannerDismissedForSessionID: UUID?
57
    @State private var showingInlineTargetEditor = false
58
    @State private var draftTargetText = ""
59
    @State private var showingStopConfirm = false
60
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
61
    @State private var isBatteryCardExpanded = false
Bogdan Timofte authored a month ago
62
    @State private var finalCheckpointText = ""
63
    @State private var stopFailureMessage: String?
64

            
65
    init(
66
        chargedDeviceID: UUID,
67
        sessionID: UUID,
68
        monitoringMeter: Meter? = nil,
69
        presentation: ChargeSessionDetailPresentation = .navigation
70
    ) {
71
        self.chargedDeviceID = chargedDeviceID
72
        self.sessionID = sessionID
73
        self.monitoringMeter = monitoringMeter
74
        self.presentation = presentation
75
    }
76

            
77
    private var chargedDevice: ChargedDeviceSummary? {
78
        appData.chargedDeviceSummary(id: chargedDeviceID)
79
    }
80

            
81
    private var session: ChargeSessionSummary? {
82
        chargedDevice?.sessions.first(where: { $0.id == sessionID })
83
    }
84

            
85
    private var liveMonitoringMeter: Meter? {
86
        guard let session,
87
              session.status.isOpen,
88
              let meterMACAddress = session.meterMACAddress else {
89
            return nil
90
        }
91

            
92
        if let monitoringMeter,
93
           monitoringMeter.btSerial.macAddress.description == meterMACAddress {
94
            return monitoringMeter
95
        }
96

            
97
        return appData.meters.values.first {
98
            $0.btSerial.macAddress.description == meterMACAddress
99
        }
100
    }
101

            
102
    private var hasMonitoringControls: Bool {
103
        session?.status.isOpen == true && liveMonitoringMeter != nil
104
    }
105

            
106
    private var shouldShowTrimBanner: Bool {
107
        guard hasMonitoringControls,
108
              let session,
109
              session.isTrimmed == false,
110
              trimBannerDismissedForSessionID != session.id,
111
              let detectedTrimWindow else {
112
            return false
113
        }
114
        return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold
115
    }
116

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

            
205
    private func content(
206
        chargedDevice: ChargedDeviceSummary,
207
        session: ChargeSessionSummary
208
    ) -> some View {
209
        ScrollView {
210
            VStack(spacing: 16) {
211
                if hasMonitoringControls {
212
                    monitoringSessionCard(session, chargedDevice: chargedDevice)
213

            
214
                    if shouldShowTrimBanner {
215
                        trimDetectionBanner(session)
216
                    }
217

            
218
                    if shouldShowSessionChart(session) {
Bogdan Timofte authored a month ago
219
                        chartCard(session, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
220
                    }
221
                } else {
222
                    overviewCard(session, chargedDevice: chargedDevice)
223
                    batteryCard(session, chargedDevice: chargedDevice)
224

            
225
                    if shouldShowSessionChart(session) {
Bogdan Timofte authored a month ago
226
                        chartCard(session, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
227
                    }
228

            
229
                    if session.status.isOpen {
230
                        followerNoticeCard(session)
231
                    }
232
                }
233
            }
234
            .padding(presentation == .embedded ? 16 : 20)
235
        }
236
        .background(
237
            LinearGradient(
238
                colors: [statusTint(for: session).opacity(0.14), Color.clear],
239
                startPoint: .topLeading,
240
                endPoint: .bottomTrailing
241
            )
242
            .ignoresSafeArea()
243
        )
244
        .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
Bogdan Timofte authored a month ago
245
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
246
        .toolbar {
247
            ToolbarItemGroup(placement: .primaryAction) {
248
                if session.status.isOpen == false {
249
                    Button(role: .destructive) {
250
                        pendingSessionDeletion = session
251
                    } label: {
252
                        Image(systemName: "trash")
253
                    }
254
                    .help("Delete session")
255
                }
256
            }
257
        }
Bogdan Timofte authored a month ago
258
    }
259

            
260
    private var unavailableState: some View {
261
        VStack(spacing: 12) {
262
            Image(systemName: "bolt.slash")
263
                .font(.title2)
264
                .foregroundColor(.secondary)
265
            Text("This session is no longer available.")
266
                .font(.headline)
267
            Text("It may have been deleted or synced from another device.")
268
                .font(.footnote)
269
                .foregroundColor(.secondary)
270
                .multilineTextAlignment(.center)
271
        }
272
        .frame(maxWidth: .infinity, maxHeight: .infinity)
273
        .padding(24)
274
        .navigationTitle("Session")
Bogdan Timofte authored a month ago
275
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
276
    }
277

            
278
    private func monitoringSessionCard(
279
        _ session: ChargeSessionSummary,
280
        chargedDevice: ChargedDeviceSummary
281
    ) -> some View {
282
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
283
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
284
            for: session,
285
            effectiveEnergyWhOverride: displayedEnergyWh
286
        )
287

            
288
        return VStack(alignment: .leading, spacing: 14) {
289
            HStack {
290
                ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16)
291
                    .font(.headline)
292

            
293
                Spacer()
294

            
295
                Text(session.status.title)
296
                    .font(.caption.weight(.bold))
297
                    .foregroundColor(monitoringStatusColor(for: session))
298
                    .padding(.horizontal, 8)
299
                    .padding(.vertical, 4)
300
                    .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
301
            }
302

            
303
            if let batteryPrediction {
304
                batteryGaugeSection(
305
                    prediction: batteryPrediction,
306
                    session: session,
307
                    displayedEnergyWh: displayedEnergyWh
308
                )
309
            }
310

            
311
            sessionMetricsGrid(
312
                session: session,
313
                chargedDevice: chargedDevice,
314
                displayedEnergyWh: displayedEnergyWh,
315
                hasPrediction: batteryPrediction != nil
316
            )
317

            
318
            if session.stopThresholdAmps > 0 {
319
                Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
320
                    .font(.caption)
321
                    .foregroundColor(.secondary)
322
            }
323

            
324
            if let sessionWarning = sessionWarning(for: session) {
325
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
326
                    .font(.caption)
327
                    .foregroundColor(.orange)
328
            }
329

            
330
            if session.isPaused {
331
                Label(
332
                    "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.",
333
                    systemImage: "pause.circle"
334
                )
335
                .font(.caption)
336
                .foregroundColor(.secondary)
337
            }
338

            
339
            if session.requiresCompletionConfirmation && !showingStopConfirm {
340
                completionConfirmationCard(session)
341
            }
342

            
343
            BatteryCheckpointSectionView(
344
                sessionID: session.id,
345
                checkpoints: session.checkpoints,
346
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
347
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id),
348
                canDeleteCheckpoint: true,
349
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id),
350
                effectiveEnergyWhOverride: displayedEnergyWh,
351
                onDelete: { checkpoint in
352
                    pendingCheckpointDeletion = checkpoint
353
                }
354
            )
355

            
356
            targetSectionView(
357
                session: session,
358
                predictedPercent: batteryPrediction?.predictedPercent
359
            )
360

            
361
            if showingStopConfirm {
362
                stopConfirmPanel(
363
                    session: session,
Bogdan Timofte authored a month ago
364
                    displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
365
                )
366
            } else {
367
                monitoringActionRow(session)
368
            }
369
        }
370
        .padding(18)
371
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
372
    }
373

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

            
402
                Divider()
403

            
404
                HStack(alignment: .top, spacing: 12) {
405
                    overviewStatCell(label: "Started", value: session.startedAt.format())
406
                    if let endedAt = session.endedAt {
407
                        overviewStatCell(label: "Ended", value: endedAt.format())
408
                    }
409
                }
410

            
411
                HStack(alignment: .top, spacing: 12) {
412
                    overviewStatCell(label: "Duration", value: sessionDurationText(session))
413
                    overviewStatCell(label: "Status", value: session.status.title)
414
                }
415

            
416
                Divider()
417

            
418
                HStack(alignment: .top, spacing: 12) {
419
                    overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title)
420
                    overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title)
421
                }
422

            
423
                HStack(alignment: .top, spacing: 12) {
424
                    overviewStatCell(label: "Source", value: session.sourceMode.title)
425
                    overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session))
426
                }
427

            
428
                if session.isTrimmed {
429
                    Divider()
430
                    HStack(alignment: .top, spacing: 12) {
431
                        overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format())
432
                        overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format())
433
                    }
434
                }
435

            
436
                let meterLabel: String? = session.meterName ?? session.meterMACAddress
437
                if meterLabel != nil || session.meterModel != nil {
438
                    Divider()
439
                    HStack(alignment: .top, spacing: 12) {
440
                        if let label = meterLabel {
441
                            overviewStatCell(label: "Meter", value: label)
442
                        }
443
                        if let model = session.meterModel {
444
                            overviewStatCell(label: "Meter Model", value: model)
445
                        }
446
                    }
447
                }
Bogdan Timofte authored a month ago
448

            
449
                if session.minimumObservedCurrentAmps != nil
450
                    || session.maximumObservedCurrentAmps != nil
451
                    || session.maximumObservedPowerWatts != nil
452
                    || session.maximumObservedVoltageVolts != nil
453
                    || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil)
454
                    || session.completionCurrentAmps != nil
455
                    || session.selectedDataGroup != nil {
456

            
457
                    Divider()
458

            
459
                    if session.minimumObservedCurrentAmps != nil || session.maximumObservedCurrentAmps != nil {
460
                        HStack(alignment: .top, spacing: 12) {
461
                            if let v = session.minimumObservedCurrentAmps {
462
                                overviewStatCell(label: "Min Current", value: "\(v.format(decimalDigits: 2)) A")
463
                            }
464
                            if let v = session.maximumObservedCurrentAmps {
465
                                overviewStatCell(label: "Max Current", value: "\(v.format(decimalDigits: 2)) A")
466
                            }
467
                        }
468
                    }
469

            
470
                    if session.maximumObservedPowerWatts != nil || session.maximumObservedVoltageVolts != nil {
471
                        HStack(alignment: .top, spacing: 12) {
472
                            if let v = session.maximumObservedPowerWatts {
473
                                overviewStatCell(label: "Max Power", value: "\(v.format(decimalDigits: 2)) W")
474
                            }
475
                            if let v = session.maximumObservedVoltageVolts {
476
                                overviewStatCell(label: "Max Voltage", value: "\(v.format(decimalDigits: 2)) V")
477
                            }
478
                        }
479
                    }
480

            
481
                    if session.completionCurrentAmps != nil
482
                        || (chargedDevice.isCharger && session.selectedSourceVoltageVolts != nil) {
483
                        HStack(alignment: .top, spacing: 12) {
484
                            if let v = session.completionCurrentAmps {
485
                                overviewStatCell(label: "Completion Current", value: "\(v.format(decimalDigits: 2)) A")
486
                            }
487
                            if chargedDevice.isCharger, let v = session.selectedSourceVoltageVolts {
488
                                overviewStatCell(label: "Selected Voltage", value: "\(v.format(decimalDigits: 2)) V")
489
                            }
490
                        }
491
                    }
492

            
493
                    if let dg = session.selectedDataGroup {
494
                        MeterInfoRowView(label: "Data Group", value: "\(dg)")
495
                    }
496
                }
Bogdan Timofte authored a month ago
497
            }
498
        }
499
    }
500

            
Bogdan Timofte authored a month ago
501
    private func overviewStatCell(label: String, value: String) -> some View {
502
        VStack(alignment: .leading, spacing: 2) {
503
            Text(label)
504
                .font(.caption2)
505
                .foregroundColor(.secondary)
506
            Text(value)
507
                .font(.footnote.weight(.medium))
508
                .monospacedDigit()
509
        }
510
        .frame(maxWidth: .infinity, alignment: .leading)
511
    }
512

            
Bogdan Timofte authored a month ago
513
    private func batteryCard(
514
        _ session: ChargeSessionSummary,
515
        chargedDevice: ChargedDeviceSummary
516
    ) -> some View {
517
        let displayedEnergyWh = displayedSessionEnergyWh(for: session)
518
        let batteryPrediction = chargedDevice.batteryLevelPrediction(
519
            for: session,
520
            effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil
521
        )
522

            
Bogdan Timofte authored a month ago
523
        let startPercent = session.startBatteryPercent
524
        let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent
525
        let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil
526
        let showsPreview = startPercent != nil && endPercent != nil
Bogdan Timofte authored a month ago
527

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

            
530
            // Header — always visible, tappable
531
            HStack(spacing: 8) {
532
                Text("Battery")
533
                    .font(.headline)
534
                Spacer(minLength: 0)
535
                Image(systemName: "chevron.up")
536
                    .font(.caption.weight(.semibold))
537
                    .foregroundColor(.secondary)
538
                    .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180))
539
                    .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded)
540
            }
541
            .contentShape(Rectangle())
542
            .onTapGesture {
543
                withAnimation(.easeInOut(duration: 0.25)) {
544
                    isBatteryCardExpanded.toggle()
Bogdan Timofte authored a month ago
545
                }
Bogdan Timofte authored a month ago
546
            }
Bogdan Timofte authored a month ago
547

            
Bogdan Timofte authored a month ago
548
            // Preview bar — always visible when there is enough data
549
            if showsPreview, let start = startPercent, let end = endPercent {
550
                batteryPreviewBar(
551
                    startPercent: start,
552
                    endPercent: end,
553
                    checkpoints: session.checkpoints,
554
                    isEstimatedEnd: isEstimatedEnd
555
                )
556
                .padding(.top, 10)
557
            }
558

            
559
            // Collapsible detail
560
            if isBatteryCardExpanded {
561
                VStack(alignment: .leading, spacing: 10) {
562

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

            
592
                    // Battery percentages
593
                    if startPercent != nil || session.endBatteryPercent != nil {
594
                        Divider()
Bogdan Timofte authored a month ago
595
                        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored a month ago
596
                            if let v = startPercent {
Bogdan Timofte authored a month ago
597
                                overviewStatCell(
598
                                    label: "Start Battery",
599
                                    value: session.startsFromFlatBattery ? "Flat" : "\(v.format(decimalDigits: 0))%"
600
                                )
Bogdan Timofte authored a month ago
601
                            }
Bogdan Timofte authored a month ago
602
                            if let v = session.endBatteryPercent {
603
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
604
                            }
605
                        }
Bogdan Timofte authored a month ago
606
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
607
                            HStack(alignment: .top, spacing: 12) {
608
                                if let v = session.batteryDeltaPercent {
609
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
610
                                }
611
                                if let v = session.targetBatteryPercent {
612
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
613
                                }
614
                            }
615
                        }
616
                        if let batteryPrediction {
617
                            HStack(alignment: .top, spacing: 12) {
618
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
619
                            }
Bogdan Timofte authored a month ago
620
                            Text(batteryPredictionExplanation(batteryPrediction))
Bogdan Timofte authored a month ago
621
                            .font(.caption2)
622
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
623
                        }
624
                    }
Bogdan Timofte authored a month ago
625

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

            
Bogdan Timofte authored a month ago
652
    private func batteryPreviewBar(
653
        startPercent: Double,
654
        endPercent: Double,
655
        checkpoints: [ChargeCheckpointSummary],
656
        isEstimatedEnd: Bool
657
    ) -> some View {
658
        let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100)
659
        let endFrac   = CGFloat(max(0, min(endPercent,   100)) / 100)
660
        let color = batteryColor(for: endPercent)
661

            
662
        return HStack(spacing: 6) {
663
            Text("\(Int(startPercent.rounded()))%")
664
                .font(.caption2.weight(.semibold))
665
                .foregroundColor(.secondary)
666
                .monospacedDigit()
667
                .frame(minWidth: 26, alignment: .trailing)
668

            
669
            GeometryReader { geo in
670
                let w = geo.size.width
671
                ZStack(alignment: .leading) {
672
                    Capsule()
673
                        .fill(Color.primary.opacity(0.10))
674

            
675
                    Rectangle()
676
                        .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72))
677
                        .frame(width: max(w * (endFrac - startFrac), 3))
678
                        .offset(x: w * startFrac)
679

            
680
                    ForEach(checkpoints, id: \.id) { cp in
681
                        let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100)
682
                        let isFinal = cp.flag == .final
683
                        Rectangle()
684
                            .fill(Color.white.opacity(isFinal ? 0.95 : 0.70))
685
                            .frame(width: isFinal ? 2.0 : 1.5, height: 10)
686
                            .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75))
Bogdan Timofte authored a month ago
687
                    }
Bogdan Timofte authored a month ago
688
                }
689
                .clipShape(Capsule())
690
            }
691
            .frame(height: 8)
692

            
693
            HStack(spacing: 1) {
694
                if isEstimatedEnd {
695
                    Text("~")
696
                        .font(.caption2)
697
                        .foregroundColor(.secondary)
698
                }
699
                Text("\(Int(endPercent.rounded()))%")
700
                    .font(.caption2.weight(.semibold))
701
                    .foregroundColor(isEstimatedEnd ? .secondary : color)
702
                    .monospacedDigit()
Bogdan Timofte authored a month ago
703
            }
Bogdan Timofte authored a month ago
704
            .frame(minWidth: 32, alignment: .leading)
Bogdan Timofte authored a month ago
705
        }
706
    }
707

            
708
    private func batteryGaugeSection(
709
        prediction: BatteryLevelPrediction,
710
        session: ChargeSessionSummary,
711
        displayedEnergyWh: Double
712
    ) -> some View {
713
        let percent = prediction.predictedPercent
714
        let color = batteryColor(for: percent)
715
        let duration = displayedSessionDuration(for: session)
716
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
717
            ? displayedEnergyWh / duration
718
            : nil
719
        let etaToFull = etaText(
720
            rateWhPerSec: rateWhPerSec,
Bogdan Timofte authored a month ago
721
            remainingWh: max((prediction.energyWh(forPercent: 100) ?? displayedEnergyWh) - displayedEnergyWh, 0),
722
            isRelevant: percent < 98 && prediction.estimatedCapacityWh != nil
Bogdan Timofte authored a month ago
723
        )
724
        let etaToTarget = etaToTargetText(
725
            session: session,
726
            prediction: prediction,
727
            displayedEnergyWh: displayedEnergyWh,
728
            rateWhPerSec: rateWhPerSec
729
        )
730

            
731
        return VStack(spacing: 10) {
732
            HStack(alignment: .lastTextBaseline, spacing: 8) {
733
                HStack(alignment: .lastTextBaseline, spacing: 3) {
734
                    Text("\(Int(percent.rounded()))")
735
                        .font(.system(size: 52, weight: .bold, design: .rounded))
736
                        .foregroundColor(color)
737
                        .monospacedDigit()
738
                    Text("%")
739
                        .font(.title2.weight(.semibold))
740
                        .foregroundColor(color.opacity(0.8))
741
                }
742

            
743
                Spacer()
744

            
Bogdan Timofte authored a month ago
745
                if let estimatedCapacityWh = prediction.estimatedCapacityWh {
746
                    VStack(alignment: .trailing, spacing: 2) {
747
                        Text("\(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
748
                            .font(.callout.weight(.bold))
749
                            .foregroundColor(.orange)
750
                            .monospacedDigit()
751
                        Text(prediction.basis.metricLabel)
752
                            .font(.caption2)
753
                            .foregroundColor(.secondary)
754
                    }
Bogdan Timofte authored a month ago
755
                }
756
            }
757

            
758
            batteryProgressBar(
759
                percent: percent,
760
                startPercent: session.startBatteryPercent,
761
                targetPercent: session.targetBatteryPercent
762
            )
763

            
764
            HStack(spacing: 14) {
765
                if let etaToFull {
766
                    etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full")
767
                }
768

            
769
                if let etaToTarget, let target = session.targetBatteryPercent {
770
                    etaPill(
771
                        icon: "bell.badge.fill",
772
                        tint: .indigo,
773
                        value: etaToTarget,
774
                        label: "to \(Int(target.rounded()))%"
775
                    )
776
                }
777

            
778
                Spacer()
779

            
780
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
781
                    .font(.caption2)
782
                    .foregroundColor(.secondary)
783
                    .multilineTextAlignment(.trailing)
784
            }
785
        }
786
        .padding(14)
787
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
788
    }
789

            
790
    private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
791
        VStack(alignment: .leading, spacing: 1) {
792
            HStack(spacing: 4) {
793
                Image(systemName: icon)
794
                    .font(.caption)
795
                    .foregroundColor(tint)
796
                Text(value)
797
                    .font(.caption.weight(.bold))
798
            }
799
            Text(label)
800
                .font(.caption2)
801
                .foregroundColor(.secondary)
802
        }
803
    }
804

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

            
844
    private func sessionMetricsGrid(
845
        session: ChargeSessionSummary,
846
        chargedDevice: ChargedDeviceSummary,
847
        displayedEnergyWh: Double,
848
        hasPrediction: Bool
849
    ) -> some View {
850
        let capacityFallback: Double? = hasPrediction ? nil : (
851
            session.capacityEstimateWh
852
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
853
                ?? chargedDevice.estimatedBatteryCapacityWh
854
        )
855
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
856

            
857
        return LazyVGrid(columns: columns, spacing: 8) {
858
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
859
            metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal)
860

            
861
            if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
862
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
863
            }
864
            if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
865
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
866
            }
867

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

            
870
            if let capacityFallback {
871
                metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange)
872
            }
873
        }
874
    }
875

            
876
    private func metricCell(label: String, value: String, tint: Color) -> some View {
877
        VStack(alignment: .leading, spacing: 3) {
878
            Text(label)
879
                .font(.caption2)
880
                .foregroundColor(.secondary)
881
            Text(value)
882
                .font(.subheadline.weight(.semibold))
883
                .lineLimit(1)
884
                .minimumScaleFactor(0.7)
885
                .monospacedDigit()
886
        }
887
        .frame(maxWidth: .infinity, alignment: .leading)
888
        .padding(.horizontal, 12)
889
        .padding(.vertical, 10)
890
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
891
    }
892

            
893
    private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
894
        VStack(alignment: .leading, spacing: 10) {
895
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
896
                .font(.subheadline.weight(.semibold))
897

            
898
            if let contradictionPercent = session.completionContradictionPercent {
899
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
900
                    .font(.caption)
901
                    .foregroundColor(.secondary)
902
            } else {
903
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
904
                    .font(.caption)
905
                    .foregroundColor(.secondary)
906
            }
907

            
908
            HStack(spacing: 10) {
909
                Button("Finish") {
910
                    beginStopConfirmation(for: session)
911
                }
912
                .frame(maxWidth: .infinity)
913
                .padding(.vertical, 9)
914
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
915
                .buttonStyle(.plain)
916

            
917
                Button("Keep Monitoring") {
918
                    _ = appData.continueChargeSessionMonitoring(sessionID: session.id)
919
                }
920
                .frame(maxWidth: .infinity)
921
                .padding(.vertical, 9)
922
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
923
                .buttonStyle(.plain)
924
            }
925
        }
926
        .padding(14)
927
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
928
    }
929

            
930
    private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
931
        let draftBelowPrediction: Bool = {
932
            guard let draft = parsedDraftTarget, let predictedPercent else { return false }
933
            return draft <= predictedPercent
934
        }()
935
        let savedBelowPrediction: Bool = {
936
            guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
937
            return saved <= predictedPercent
938
        }()
939

            
940
        return HStack(alignment: .center, spacing: 8) {
941
            Image(systemName: "bell.badge")
942
                .foregroundColor(.indigo)
943
                .font(.subheadline)
944

            
945
            Text("Notify at")
946
                .font(.subheadline.weight(.semibold))
947

            
948
            Spacer(minLength: 8)
949

            
950
            if showingInlineTargetEditor {
951
                targetEditorControls(
952
                    session: session,
953
                    draftBelowPrediction: draftBelowPrediction,
954
                    predictedPercent: predictedPercent
955
                )
956
            } else {
957
                savedTargetControls(
958
                    session: session,
959
                    savedBelowPrediction: savedBelowPrediction,
960
                    predictedPercent: predictedPercent
961
                )
962
            }
963
        }
964
    }
965

            
966
    private func targetEditorControls(
967
        session: ChargeSessionSummary,
968
        draftBelowPrediction: Bool,
969
        predictedPercent: Double?
970
    ) -> some View {
971
        Group {
972
            Button {
973
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
974
                draftTargetText = max(current - 1, 1).format(decimalDigits: 0)
975
            } label: {
976
                Image(systemName: "minus.circle")
977
                    .font(.title3)
978
            }
979
            .buttonStyle(.plain)
980

            
981
            TextField("-", text: $draftTargetText)
982
                .keyboardType(.decimalPad)
983
                .textFieldStyle(.roundedBorder)
984
                .frame(width: 48)
985
                .multilineTextAlignment(.center)
986
                .foregroundColor(draftBelowPrediction ? .orange : .primary)
987

            
988
            Text("%")
989
                .font(.subheadline)
990
                .foregroundColor(.secondary)
991

            
992
            if draftBelowPrediction, let predictedPercent {
993
                predictionWarningButton(predictedPercent: predictedPercent)
994
            }
995

            
996
            Button {
997
                let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
998
                draftTargetText = min(current + 1, 100).format(decimalDigits: 0)
999
            } label: {
1000
                Image(systemName: "plus.circle")
1001
                    .font(.title3)
1002
            }
1003
            .buttonStyle(.plain)
1004

            
1005
            Button {
1006
                if let value = parsedDraftTarget {
1007
                    _ = appData.setTargetBatteryPercent(value, for: session.id)
1008
                }
1009
                showingInlineTargetEditor = false
1010
            } label: {
1011
                Image(systemName: "checkmark.circle.fill")
1012
                    .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1013
                    .font(.title3)
1014
            }
1015
            .buttonStyle(.plain)
1016
            .disabled(parsedDraftTarget == nil)
1017

            
1018
            Button {
1019
                showingInlineTargetEditor = false
1020
                draftTargetText = ""
1021
            } label: {
1022
                Image(systemName: "xmark.circle")
1023
                    .foregroundColor(.secondary)
1024
                    .font(.title3)
1025
            }
1026
            .buttonStyle(.plain)
1027
        }
1028
    }
1029

            
1030
    private func savedTargetControls(
1031
        session: ChargeSessionSummary,
1032
        savedBelowPrediction: Bool,
1033
        predictedPercent: Double?
1034
    ) -> some View {
1035
        Group {
1036
            if let targetPercent = session.targetBatteryPercent {
1037
                Text("\(targetPercent.format(decimalDigits: 0))%")
1038
                    .font(.subheadline.weight(.semibold))
1039
                    .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1040

            
1041
                if savedBelowPrediction, let predictedPercent {
1042
                    predictionWarningButton(predictedPercent: predictedPercent)
1043
                }
1044

            
1045
                Button {
1046
                    _ = appData.setTargetBatteryPercent(nil, for: session.id)
1047
                } label: {
1048
                    Image(systemName: "xmark.circle.fill")
1049
                        .foregroundColor(.secondary)
1050
                        .font(.callout)
1051
                }
1052
                .buttonStyle(.plain)
1053
                .help("Remove alert")
1054
            }
1055

            
1056
            Button {
1057
                draftTargetText = session.targetBatteryPercent.map {
1058
                    $0.format(decimalDigits: 0)
1059
                } ?? "80"
1060
                showingInlineTargetEditor = true
1061
            } label: {
1062
                Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1063
                    .font(.caption.weight(.semibold))
1064
                    .frame(width: 30, height: 30)
1065
                    .contentShape(Rectangle())
1066
            }
1067
            .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1068
            .buttonStyle(.plain)
1069
            .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
1070
        }
1071
    }
1072

            
1073
    private func predictionWarningButton(predictedPercent: Double) -> some View {
1074
        Button {} label: {
1075
            Image(systemName: "exclamationmark.triangle.fill")
1076
                .font(.callout.weight(.semibold))
1077
                .foregroundColor(.orange)
1078
        }
1079
        .buttonStyle(.plain)
1080
        .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.")
1081
    }
1082

            
1083
    private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
1084
        HStack(spacing: 10) {
1085
            if session.status == .active {
1086
                Button("Pause") {
1087
                    _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1088
                }
1089
                .monitoringActionStyle(tint: .orange)
1090
            } else if session.status == .paused {
1091
                Button("Resume") {
1092
                    _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter)
1093
                }
1094
                .monitoringActionStyle(tint: .blue)
1095
            }
1096

            
1097
            Button("Terminate Session") {
1098
                beginStopConfirmation(for: session)
1099
            }
1100
            .monitoringActionStyle(tint: .red)
1101
        }
1102
    }
1103

            
1104
    private func stopConfirmPanel(
1105
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1106
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1107
    ) -> some View {
1108
        let canSave = hasSavableChargeData(
1109
            session: session,
Bogdan Timofte authored a month ago
1110
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1111
        )
1112
        let saveDisabledReason = saveDisabledReason(
1113
            session: session,
Bogdan Timofte authored a month ago
1114
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1115
        )
1116
        let isSaveEnabled = saveDisabledReason == nil
1117

            
1118
        return VStack(alignment: .leading, spacing: 12) {
1119
            HStack {
1120
                Text("Final Checkpoint")
1121
                    .font(.subheadline.weight(.semibold))
1122
                Text("optional")
1123
                    .font(.caption2.weight(.semibold))
1124
                    .foregroundColor(.secondary)
1125
            }
1126

            
1127
            finalCheckpointPicker(session)
1128

            
1129
            if finalCheckpointMode == .custom {
1130
                customFinalCheckpointRow
1131
            }
1132

            
1133
            if let saveDisabledReason {
1134
                Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
1135
                    .font(.caption)
1136
                    .foregroundColor(.red)
1137
                    .fixedSize(horizontal: false, vertical: true)
1138
            } else if let stopFailureMessage {
1139
                Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill")
1140
                    .font(.caption)
1141
                    .foregroundColor(.red)
1142
                    .fixedSize(horizontal: false, vertical: true)
1143
            } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
1144
                Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
1145
                    .font(.caption)
1146
                    .foregroundColor(.green)
1147
                    .fixedSize(horizontal: false, vertical: true)
1148
            }
1149

            
1150
            HStack(spacing: 8) {
1151
                Button("Discard") {
1152
                    discardSession(session)
1153
                }
1154
                .monitoringPanelActionStyle(tint: .secondary)
1155

            
1156
                Button {
1157
                    stopSession(
1158
                        session,
Bogdan Timofte authored a month ago
1159
                        displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1160
                    )
1161
                } label: {
1162
                    Label("Save Session", systemImage: "checkmark.circle.fill")
1163
                        .frame(maxWidth: .infinity)
1164
                }
1165
                .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled)
1166
                .disabled(!isSaveEnabled)
1167
                .help(saveDisabledReason ?? "Close and save this session")
1168

            
1169
                Button("Cancel") {
1170
                    resetStopConfirmation()
1171
                }
1172
                .monitoringPanelActionStyle(tint: .secondary)
1173
            }
1174
        }
1175
        .padding(14)
1176
        .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
1177
    }
1178

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

            
1213
    private var customFinalCheckpointRow: some View {
1214
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1215
            || parsedFinalCheckpoint == nil
1216

            
1217
        return HStack(spacing: 8) {
1218
            Button {
1219
                adjustFinalCheckpoint(by: -1)
1220
            } label: {
1221
                Image(systemName: "minus.circle").font(.title3)
1222
            }
1223
            .buttonStyle(.plain)
1224

            
1225
            TextField("-", text: $finalCheckpointText)
1226
                .keyboardType(.decimalPad)
1227
                .textFieldStyle(.roundedBorder)
1228
                .frame(width: 56)
1229
                .multilineTextAlignment(.center)
1230
                .overlay(
1231
                    RoundedRectangle(cornerRadius: 6)
1232
                        .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1)
1233
                )
1234

            
1235
            Text("%").foregroundColor(.secondary)
1236

            
1237
            Text("required")
1238
                .font(.caption2.weight(.semibold))
1239
                .foregroundColor(isInvalid ? .red : .secondary)
1240

            
1241
            Button {
1242
                adjustFinalCheckpoint(by: 1)
1243
            } label: {
1244
                Image(systemName: "plus.circle").font(.title3)
1245
            }
1246
            .buttonStyle(.plain)
1247

            
1248
            Spacer()
1249
        }
1250
    }
1251

            
1252
    private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
1253
        MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
1254
            if let meterName = session.meterName {
1255
                MeterInfoRowView(label: "Controlled On", value: meterName)
1256
            }
1257
            Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
1258
                .font(.caption2)
1259
                .foregroundColor(.secondary)
1260
        }
1261
    }
1262

            
1263
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
1264
        MeterInfoCardView(title: "Administration", tint: .red) {
1265
            Button(role: .destructive) {
1266
                pendingSessionDeletion = session
1267
            } label: {
1268
                Label("Delete Session", systemImage: "trash")
1269
                    .font(.subheadline.weight(.semibold))
1270
                    .frame(maxWidth: .infinity)
1271
                    .padding(.vertical, 10)
1272
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
1273
            }
1274
            .buttonStyle(.plain)
1275
        }
1276
    }
1277

            
1278
    @ViewBuilder
1279
    private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
1280
        if let window = detectedTrimWindow {
1281
            HStack(spacing: 12) {
1282
                Image(systemName: "scissors.circle.fill")
1283
                    .font(.title3)
1284
                    .foregroundColor(.blue)
1285

            
1286
                VStack(alignment: .leading, spacing: 2) {
1287
                    Text("Charging ended early")
1288
                        .font(.subheadline.weight(.semibold))
1289
                    Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
1290
                        .font(.caption)
1291
                        .foregroundColor(.secondary)
1292
                        .fixedSize(horizontal: false, vertical: true)
1293
                }
1294

            
1295
                Spacer(minLength: 0)
1296

            
1297
                VStack(spacing: 6) {
1298
                    Button("Trim Start") {
1299
                        setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd)
1300
                        trimBannerDismissedForSessionID = session.id
1301
                    }
1302
                    .font(.caption.weight(.semibold))
1303
                    .buttonStyle(.borderedProminent)
1304
                    .controlSize(.small)
1305
                    .tint(.blue)
1306

            
1307
                    Button("End & Finish") {
1308
                        requestStop(
1309
                            session,
1310
                            applyingTrimStart: session.trimStart ?? window.start,
1311
                            trimEnd: window.end,
1312
                            title: "Trim End & Finish",
1313
                            confirmTitle: "Finish",
1314
                            explanation: "The detected charging window will be saved before the session is closed."
1315
                        )
1316
                        trimBannerDismissedForSessionID = session.id
1317
                    }
1318
                    .font(.caption.weight(.semibold))
1319
                    .buttonStyle(.bordered)
1320
                    .controlSize(.small)
1321
                    .tint(.red)
1322
                }
1323
            }
1324
            .padding(14)
1325
            .background(
1326
                RoundedRectangle(cornerRadius: 14)
1327
                    .fill(Color.blue.opacity(0.10))
1328
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1329
            )
1330
            .transition(.opacity.combined(with: .move(edge: .top)))
1331
        }
1332
    }
1333

            
1334
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1335
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1336
    }
1337

            
Bogdan Timofte authored a month ago
1338
    private func chartCard(
1339
        _ session: ChargeSessionSummary,
1340
        chargedDevice: ChargedDeviceSummary
1341
    ) -> some View {
Bogdan Timofte authored a month ago
1342
        ChargeSessionChartCardView(
1343
            session: session,
1344
            monitoringMeter: liveMonitoringMeter,
Bogdan Timofte authored a month ago
1345
            batteryPercentPoints: batteryPercentChartPoints(
1346
                for: session,
1347
                chargedDevice: chargedDevice
1348
            ),
Bogdan Timofte authored a month ago
1349
            controlMode: chartControlMode(for: session),
1350
            onSetTrim: { start, end in
1351
                setSessionTrim(sessionID: session.id, start: start, end: end)
1352
            },
1353
            onStopWithTrim: { start, end in
1354
                requestStop(
1355
                    session,
1356
                    applyingTrimStart: start,
1357
                    trimEnd: end,
1358
                    title: "Trim End & Finish",
1359
                    confirmTitle: "Finish",
1360
                    explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1361
                )
Bogdan Timofte authored a month ago
1362
            },
1363
            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1364
                ? {
1365
                    pendingTrimCommitSession = session
1366
                }
1367
                : nil
Bogdan Timofte authored a month ago
1368
        )
1369
    }
1370

            
1371
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1372
        if hasMonitoringControls {
1373
            return .activeMonitoring
1374
        }
1375

            
1376
        if session.status.isOpen == false {
1377
            return .closed
1378
        }
1379

            
1380
        return .none
1381
    }
1382

            
1383
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1384
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1385
        trimBannerDismissedForSessionID = sessionID
1386
    }
1387

            
Bogdan Timofte authored a month ago
1388
    private func batteryPercentChartPoints(
1389
        for session: ChargeSessionSummary,
1390
        chargedDevice: ChargedDeviceSummary
1391
    ) -> [Measurements.Measurement.Point] {
1392
        var candidates: [BatteryPercentCandidate] = []
1393

            
1394
        for sample in session.displayedAggregatedSamples {
1395
            let percent = chargedDevice.batteryLevelPrediction(
1396
                    for: session,
1397
                    effectiveEnergyWhOverride: effectiveBatteryEnergyWh(
1398
                        rawMeasuredEnergyWh: sample.measuredEnergyWh,
1399
                        for: session
1400
                    ),
1401
                    referenceTimestamp: sample.timestamp
1402
                )?.predictedPercent
1403
                ?? sample.estimatedBatteryPercent
1404

            
1405
            if let percent, percent.isFinite {
1406
                candidates.append(
1407
                    BatteryPercentCandidate(
1408
                        timestamp: sample.timestamp,
1409
                        percent: percent,
1410
                        isCheckpoint: false
1411
                    )
1412
                )
1413
            }
1414
        }
1415

            
1416
        for checkpoint in session.checkpoints where session.effectiveTimeRange.contains(checkpoint.timestamp) {
1417
            guard checkpoint.batteryPercent.isFinite,
1418
                  checkpoint.batteryPercent >= 0,
1419
                  checkpoint.batteryPercent <= 100 else {
1420
                continue
1421
            }
1422
            candidates.append(
1423
                BatteryPercentCandidate(
1424
                    timestamp: checkpoint.timestamp,
1425
                    percent: checkpoint.batteryPercent,
1426
                    isCheckpoint: true
1427
                )
1428
            )
1429
        }
1430

            
1431
        if hasMonitoringControls,
1432
           let prediction = chargedDevice.batteryLevelPrediction(
1433
            for: session,
1434
            effectiveEnergyWhOverride: displayedSessionEnergyWh(for: session)
1435
           ) {
1436
            candidates.append(
1437
                BatteryPercentCandidate(
1438
                    timestamp: max(session.lastObservedAt, Date()),
1439
                    percent: prediction.predictedPercent,
1440
                    isCheckpoint: false
1441
                )
1442
            )
1443
        }
1444

            
1445
        let sortedCandidates = coalescedBatteryPercentCandidates(candidates).sorted { lhs, rhs in
1446
            if lhs.timestamp != rhs.timestamp {
1447
                return lhs.timestamp < rhs.timestamp
1448
            }
1449
            return lhs.isCheckpoint && !rhs.isCheckpoint
1450
        }
1451

            
1452
        var points: [Measurements.Measurement.Point] = []
1453
        var previousCandidate: BatteryPercentCandidate?
1454

            
1455
        for candidate in sortedCandidates {
1456
            if let previousCandidate,
1457
               candidate.timestamp.timeIntervalSince(previousCandidate.timestamp) > 90 {
1458
                points.append(
1459
                    Measurements.Measurement.Point(
1460
                        id: points.count,
1461
                        timestamp: candidate.timestamp,
1462
                        value: points.last?.value ?? candidate.percent,
1463
                        kind: .discontinuity
1464
                    )
1465
                )
1466
            }
1467

            
1468
            points.append(
1469
                Measurements.Measurement.Point(
1470
                    id: points.count,
1471
                    timestamp: candidate.timestamp,
1472
                    value: min(max(candidate.percent, 0), 100)
1473
                )
1474
            )
1475
            previousCandidate = candidate
1476
        }
1477

            
1478
        return points
1479
    }
1480

            
1481
    private func coalescedBatteryPercentCandidates(
1482
        _ candidates: [BatteryPercentCandidate]
1483
    ) -> [BatteryPercentCandidate] {
1484
        let sortedCandidates = candidates.sorted { lhs, rhs in
1485
            if lhs.timestamp != rhs.timestamp {
1486
                return lhs.timestamp < rhs.timestamp
1487
            }
1488
            return lhs.isCheckpoint && !rhs.isCheckpoint
1489
        }
1490

            
1491
        var coalesced: [BatteryPercentCandidate] = []
1492

            
1493
        for candidate in sortedCandidates {
1494
            if let last = coalesced.last,
1495
               abs(candidate.timestamp.timeIntervalSince(last.timestamp)) <= 1 {
1496
                if candidate.isCheckpoint || !last.isCheckpoint {
1497
                    coalesced[coalesced.count - 1] = candidate
1498
                }
1499
            } else {
1500
                coalesced.append(candidate)
1501
            }
1502
        }
1503

            
1504
        return coalesced
1505
    }
1506

            
1507
    private func effectiveBatteryEnergyWh(
1508
        rawMeasuredEnergyWh: Double,
1509
        for session: ChargeSessionSummary
1510
    ) -> Double {
1511
        switch session.chargingTransportMode {
1512
        case .wired:
1513
            return rawMeasuredEnergyWh
1514
        case .wireless:
1515
            if let factor = session.wirelessEfficiencyFactor, factor > 0 {
1516
                return rawMeasuredEnergyWh * factor
1517
            }
1518
            if let effectiveEnergyWh = session.effectiveBatteryEnergyWh,
1519
               session.measuredEnergyWh > 0 {
1520
                return rawMeasuredEnergyWh * (effectiveEnergyWh / session.measuredEnergyWh)
1521
            }
1522
            return rawMeasuredEnergyWh
1523
        }
1524
    }
1525

            
Bogdan Timofte authored a month ago
1526
    private func requestStop(
1527
        _ session: ChargeSessionSummary,
1528
        applyingTrimStart trimStart: Date?,
1529
        trimEnd: Date?,
1530
        title: String,
1531
        confirmTitle: String,
1532
        explanation: String
1533
    ) {
1534
        pendingSessionStopRequest = ChargeSessionStopRequest(
1535
            sessionID: session.id,
1536
            title: title,
1537
            confirmTitle: confirmTitle,
1538
            explanation: explanation,
1539
            appliesTrim: trimStart != nil || trimEnd != nil,
1540
            trimStart: trimStart,
1541
            trimEnd: trimEnd
1542
        )
1543
    }
1544

            
1545
    private var parsedDraftTarget: Double? {
1546
        let normalized = draftTargetText
1547
            .trimmingCharacters(in: .whitespacesAndNewlines)
1548
            .replacingOccurrences(of: ",", with: ".")
1549
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1550
        return value
1551
    }
1552

            
1553
    private var parsedFinalCheckpoint: Double? {
1554
        let normalized = finalCheckpointText
1555
            .trimmingCharacters(in: .whitespacesAndNewlines)
1556
            .replacingOccurrences(of: ",", with: ".")
1557
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1558
        return value
1559
    }
1560

            
1561
    private var resolvedFinalCheckpoint: Double? {
1562
        switch finalCheckpointMode {
Bogdan Timofte authored a month ago
1563
        case .full:   return suggestedFinalCheckpointPercent(for: session)
Bogdan Timofte authored a month ago
1564
        case .skip:   return nil
1565
        case .custom: return parsedFinalCheckpoint
1566
        }
1567
    }
1568

            
1569
    private func adjustFinalCheckpoint(by delta: Double) {
1570
        let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0
1571
        let next = min(max(current + delta, 0), 100)
1572
        finalCheckpointText = next.format(decimalDigits: 0)
1573
    }
1574

            
1575
    private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
1576
        guard let session else { return nil }
1577
        if let endBatteryPercent = session.endBatteryPercent {
1578
            return endBatteryPercent
1579
        }
1580
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
1581
            return latestCheckpoint.batteryPercent
1582
        }
1583
        return session.targetBatteryPercent ?? session.completionContradictionPercent
1584
    }
1585

            
1586
    private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
1587
        guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
1588
              let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
1589
            return
1590
        }
1591
        finalCheckpointText = suggestedPercent.format(decimalDigits: 0)
1592
    }
1593

            
1594
    private func hasSavableChargeData(
1595
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1596
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1597
    ) -> Bool {
1598
        session.hasSavableChargeData
1599
            || displayedEnergyWh > 0
1600
    }
1601

            
1602
    private func saveDisabledReason(
1603
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1604
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1605
    ) -> String? {
1606
        if finalCheckpointMode == .custom {
1607
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1608
            if trimmed.isEmpty {
1609
                return "Enter the final battery percentage or choose Skip."
1610
            }
1611
            if parsedFinalCheckpoint == nil {
1612
                return "Final battery percentage must be between 0 and 100."
1613
            }
1614
        }
Bogdan Timofte authored a month ago
1615
        if finalCheckpointMode == .full && resolvedFinalCheckpoint == nil {
1616
            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
1617
        }
Bogdan Timofte authored a month ago
1618

            
1619
        guard hasSavableChargeData(
1620
            session: session,
Bogdan Timofte authored a month ago
1621
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1622
        ) else {
1623
            return "This session has no charging data to save. Discard it instead."
1624
        }
1625

            
1626
        return nil
1627
    }
1628

            
1629
    private func stopSession(
1630
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1631
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1632
    ) {
1633
        stopFailureMessage = nil
1634

            
1635
        if let saveDisabledReason = saveDisabledReason(
1636
            session: session,
Bogdan Timofte authored a month ago
1637
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1638
        ) {
1639
            stopFailureMessage = saveDisabledReason
1640
            return
1641
        }
1642

            
1643
        let didSave = appData.stopChargeSession(
1644
            sessionID: session.id,
1645
            finalBatteryPercent: resolvedFinalCheckpoint,
1646
            from: liveMonitoringMeter
1647
        )
1648
        if didSave {
1649
            resetStopConfirmation()
1650
        } else {
1651
            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."
1652
        }
1653
    }
1654

            
1655
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1656
        finalCheckpointMode = .skip
1657
        finalCheckpointText = ""
1658
        stopFailureMessage = nil
1659
        showingStopConfirm = true
1660
    }
1661

            
1662
    private func discardSession(_ session: ChargeSessionSummary) {
1663
        _ = appData.deleteChargeSession(sessionID: session.id)
1664
        resetStopConfirmation()
1665
    }
1666

            
1667
    private func resetStopConfirmation() {
1668
        showingStopConfirm = false
1669
        finalCheckpointText = ""
1670
        finalCheckpointMode = .skip
1671
        stopFailureMessage = nil
1672
    }
1673

            
1674
    private func syncMonitoringRestore() {
1675
        guard let session,
1676
              session.status.isOpen,
1677
              let liveMonitoringMeter,
1678
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1679
            return
1680
        }
1681
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1682
    }
1683

            
1684
    private func runTrimDetection() {
1685
        guard hasMonitoringControls,
1686
              let session,
1687
              session.isTrimmed == false,
1688
              !session.aggregatedSamples.isEmpty else {
1689
            detectedTrimWindow = nil
1690
            return
1691
        }
1692

            
1693
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1694
        detectedTrimWindow = ChargingWindowDetector.detect(
1695
            samples: session.aggregatedSamples,
1696
            sessionStart: session.startedAt,
1697
            sessionEnd: sessionEnd
1698
        )
1699
    }
1700

            
1701
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1702
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1703
        guard session.isTrimmed == false else { return storedEnergyWh }
1704
        guard session.status.isOpen else { return storedEnergyWh }
1705
        guard let liveMonitoringMeter else { return storedEnergyWh }
1706
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1707
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1708
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1709
        }
1710
        return storedEnergyWh
1711
    }
1712

            
1713
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1714
        let storedDuration = max(session.effectiveDuration, 0)
1715
        guard session.isTrimmed == false else { return storedDuration }
1716
        guard session.status.isOpen else { return storedDuration }
1717
        guard let liveMonitoringMeter else { return storedDuration }
1718
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1719
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1720
    }
1721

            
1722
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1723
        let displayedDuration = displayedSessionDuration(for: session)
1724
        let formatter = DateComponentsFormatter()
1725
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1726
        formatter.unitsStyle = .abbreviated
1727
        formatter.zeroFormattingBehavior = .dropAll
1728
        return formatter.string(from: displayedDuration) ?? "0m"
1729
    }
1730

            
1731
    private func formatDuration(_ duration: TimeInterval) -> String {
1732
        let totalSeconds = Int(duration.rounded(.down))
1733
        let hours = totalSeconds / 3600
1734
        let minutes = (totalSeconds % 3600) / 60
1735
        let seconds = totalSeconds % 60
1736
        if hours > 0 {
1737
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1738
        }
1739
        return String(format: "%02d:%02d", minutes, seconds)
1740
    }
1741

            
1742
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1743
        if session.autoStopEnabled == false {
1744
            return "Manual"
1745
        }
1746

            
1747
        if let sessionWarning = sessionWarning(for: session),
1748
           sessionWarning.contains("idle-current") {
1749
            return "Blocked by charger setup"
1750
        }
1751

            
1752
        if session.stopThresholdAmps > 0 {
1753
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1754
        }
1755

            
1756
        return "Learning"
1757
    }
1758

            
1759
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1760
        if session.autoStopEnabled == false {
1761
            return "Manual"
1762
        }
1763
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1764
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1765
        }
1766
        if session.stopThresholdAmps > 0 {
1767
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1768
        }
1769
        return "Learning"
1770
    }
1771

            
1772
    private func shouldShowChargingTransport(
1773
        for session: ChargeSessionSummary,
1774
        chargedDevice: ChargedDeviceSummary
1775
    ) -> Bool {
1776
        chargedDevice.supportedChargingModes.count > 1
1777
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1778
    }
1779

            
1780
    private func shouldShowChargingState(
1781
        for session: ChargeSessionSummary,
1782
        chargedDevice: ChargedDeviceSummary
1783
    ) -> Bool {
1784
        chargedDevice.supportedChargingStateModes.count > 1
1785
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1786
    }
1787

            
1788
    private func batteryColor(for percent: Double) -> Color {
1789
        if percent >= 75 { return .green }
1790
        if percent >= 35 { return .orange }
1791
        return .red
1792
    }
1793

            
1794
    private func etaText(
1795
        rateWhPerSec: Double?,
1796
        remainingWh: Double,
1797
        isRelevant: Bool
1798
    ) -> String? {
1799
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1800
        let seconds = remainingWh / rateWhPerSec
1801
        return seconds > 120 ? formatETA(seconds) : nil
1802
    }
1803

            
1804
    private func etaToTargetText(
1805
        session: ChargeSessionSummary,
1806
        prediction: BatteryLevelPrediction,
1807
        displayedEnergyWh: Double,
1808
        rateWhPerSec: Double?
1809
    ) -> String? {
1810
        guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
1811
            return nil
1812
        }
Bogdan Timofte authored a month ago
1813
        guard let targetEnergyWh = prediction.energyWh(forPercent: target) else {
1814
            return nil
1815
        }
Bogdan Timofte authored a month ago
1816
        return etaText(
1817
            rateWhPerSec: rateWhPerSec,
1818
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1819
            isRelevant: true
1820
        )
1821
    }
1822

            
Bogdan Timofte authored a month ago
1823
    private func batteryPredictionExplanation(_ prediction: BatteryLevelPrediction) -> String {
1824
        let anchor = "Anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%"
1825
        guard let estimatedCapacityWh = prediction.estimatedCapacityWh else {
1826
            return "\(anchor)."
1827
        }
1828
        return "\(anchor) using \(estimatedCapacityWh.format(decimalDigits: 2)) Wh \(prediction.basis.explanatoryLabel)."
1829
    }
1830

            
Bogdan Timofte authored a month ago
1831
    private func formatETA(_ seconds: TimeInterval) -> String {
1832
        let totalMinutes = Int(seconds / 60)
1833
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1834
        let hours = totalMinutes / 60
1835
        let minutes = totalMinutes % 60
1836
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1837
    }
1838

            
1839
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1840
        switch session.status {
1841
        case .active:
1842
            return .red
1843
        case .paused:
1844
            return .orange
1845
        case .completed:
1846
            return .green
1847
        case .abandoned:
1848
            return .secondary
1849
        }
1850
    }
1851

            
1852
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1853
        nil
Bogdan Timofte authored a month ago
1854
    }
1855

            
1856
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1857
        guard session.chargingTransportMode == .wireless else {
1858
            return nil
1859
        }
1860

            
1861
        var components: [String] = []
1862
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1863
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1864
        }
1865
        if session.usesEstimatedWirelessEfficiency {
1866
            components.append("Estimated from wired baseline and checkpoints")
1867
        }
1868
        if session.shouldWarnAboutLowWirelessEfficiency {
1869
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1870
        }
1871

            
1872
        return components.isEmpty ? nil : components.joined(separator: " - ")
1873
    }
1874

            
1875
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1876
        switch session.status {
1877
        case .active:
1878
            return .green
1879
        case .paused:
1880
            return .orange
1881
        case .completed:
1882
            return .teal
1883
        case .abandoned:
1884
            return .secondary
1885
        }
1886
    }
1887
}
1888

            
1889
enum ChargeSessionChartControlMode {
1890
    case none
1891
    case activeMonitoring
1892
    case closed
1893
}
1894

            
1895
struct ChargeSessionChartCardView: View {
1896
    let session: ChargeSessionSummary
1897
    let monitoringMeter: Meter?
Bogdan Timofte authored a month ago
1898
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
1899
    let controlMode: ChargeSessionChartControlMode
1900
    let onSetTrim: (Date?, Date?) -> Void
1901
    let onStopWithTrim: (Date?, Date?) -> Void
Bogdan Timofte authored a month ago
1902
    let onCommitTrim: (() -> Void)?
Bogdan Timofte authored a month ago
1903

            
1904
    @StateObject private var storedMeasurements = Measurements()
1905

            
1906
    private var chartMeasurements: Measurements {
1907
        if let monitoringMeter,
1908
           session.status.isOpen,
1909
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1910
            return monitoringMeter.chargeRecordMeasurements
1911
        }
1912
        return storedMeasurements
1913
    }
1914

            
1915
    private var fullTimeRange: ClosedRange<Date> {
1916
        let start = session.startedAt
1917
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1918
        return start...end
1919
    }
1920

            
1921
    private var fixedTimeRange: ClosedRange<Date>? {
1922
        if monitoringMeter != nil && session.status.isOpen {
1923
            return nil
1924
        }
1925
        return session.effectiveTimeRange
1926
    }
1927

            
1928
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1929
        guard monitoringMeter != nil && session.status.isOpen else {
1930
            return (nil, nil)
1931
        }
1932
        return (session.trimStart, session.trimEnd)
1933
    }
1934

            
1935
    private var showsRangeSelector: Bool {
1936
        controlMode != .none && !session.aggregatedSamples.isEmpty
1937
    }
1938

            
1939
    var body: some View {
1940
        VStack(alignment: .leading, spacing: 12) {
1941
            HStack(spacing: 8) {
1942
                Image(systemName: "chart.xyaxis.line")
1943
                    .foregroundColor(.blue)
1944
                Text("Session Chart")
1945
                    .font(.headline)
1946
                ContextInfoButton(
1947
                    title: "Session Chart",
1948
                    message: chartInfoMessage
1949
                )
1950
                Spacer(minLength: 0)
1951
            }
1952

            
1953
            MeasurementChartView(
1954
                timeRange: fixedTimeRange,
1955
                timeRangeLowerBound: liveTrimBounds.lower,
1956
                timeRangeUpperBound: liveTrimBounds.upper,
1957
                showsRangeSelector: showsRangeSelector,
1958
                rebasesEnergyToVisibleRangeStart: true,
1959
                extendsTimelineToPresent: false,
1960
                showsTemperatureSeries: false,
Bogdan Timofte authored a month ago
1961
                showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
1962
                batteryCheckpoints: session.checkpoints,
1963
                batteryPercentPoints: batteryPercentPoints,
Bogdan Timofte authored a month ago
1964
                rangeSelectorConfiguration: rangeSelectorConfiguration
1965
            )
1966
            .environmentObject(chartMeasurements)
1967
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
1968

            
1969
            if let onCommitTrim {
1970
                Divider()
1971

            
1972
                HStack(alignment: .center, spacing: 10) {
1973
                    Label("Save trim permanently", systemImage: "internaldrive")
1974
                        .font(.caption.weight(.semibold))
1975
                        .foregroundColor(.secondary)
1976

            
1977
                    Spacer(minLength: 0)
1978

            
1979
                    Button {
1980
                        onCommitTrim()
1981
                    } label: {
1982
                        Label("Save Trim", systemImage: "checkmark.seal")
1983
                            .font(.caption.weight(.semibold))
1984
                    }
1985
                    .buttonStyle(.borderedProminent)
1986
                    .controlSize(.small)
1987
                    .tint(.red)
1988
                }
1989
            }
Bogdan Timofte authored a month ago
1990
        }
1991
        .padding(18)
1992
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1993
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1994
        .onChange(of: session.id) { _ in
1995
            restoreStoredMeasurementsIfNeeded()
1996
        }
1997
        .onChange(of: session.aggregatedSamples.count) { _ in
1998
            restoreStoredMeasurementsIfNeeded()
1999
        }
Bogdan Timofte authored a month ago
2000
        .onChange(of: session.checkpoints.count) { _ in
2001
            restoreStoredMeasurementsIfNeeded()
2002
        }
Bogdan Timofte authored a month ago
2003
    }
2004

            
2005
    private var chartInfoMessage: String {
2006
        if monitoringMeter != nil && session.status.isOpen {
2007
            return "This chart combines the persisted session curve with current live data from this meter."
2008
        }
2009

            
2010
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
2011
    }
2012

            
Bogdan Timofte authored a month ago
2013
    private var shouldShowBatteryPercentSeries: Bool {
2014
        !batteryPercentPoints.isEmpty
2015
    }
2016

            
Bogdan Timofte authored a month ago
2017
    private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
2018
        switch controlMode {
2019
        case .none:
2020
            return nil
2021
        case .activeMonitoring:
2022
            return MeasurementChartRangeSelectorConfiguration(
2023
                keepAction: MeasurementChartSelectionAction(
2024
                    title: "Trim Start",
2025
                    shortTitle: "Start",
2026
                    systemName: "arrow.right.to.line",
2027
                    tone: .destructive,
2028
                    handler: applyActiveStartTrim
2029
                ),
2030
                removeAction: MeasurementChartSelectionAction(
2031
                    title: "Trim End & Finish",
2032
                    shortTitle: "End",
2033
                    systemName: "arrow.left.to.line",
2034
                    tone: .destructiveProminent,
2035
                    handler: requestActiveEndTrim
2036
                ),
2037
                resetAction: MeasurementChartResetAction(
2038
                    title: "Reset Trim",
2039
                    shortTitle: "Reset",
2040
                    systemName: "arrow.counterclockwise",
2041
                    tone: .reversible,
2042
                    confirmationTitle: "Reset session trim?",
2043
                    confirmationButtonTitle: "Reset trim",
2044
                    handler: {
2045
                        onSetTrim(nil, nil)
2046
                    }
Bogdan Timofte authored a month ago
2047
                ),
2048
                exportAction: sessionCSVExportAction
Bogdan Timofte authored a month ago
2049
            )
2050
        case .closed:
2051
            return MeasurementChartRangeSelectorConfiguration(
2052
                keepAction: MeasurementChartSelectionAction(
2053
                    title: "Trim Window",
2054
                    shortTitle: "Trim",
2055
                    systemName: "scissors",
2056
                    tone: .destructive,
2057
                    handler: applyClosedTrim
2058
                ),
2059
                removeAction: nil,
2060
                resetAction: MeasurementChartResetAction(
2061
                    title: "Reset Trim",
2062
                    shortTitle: "Reset",
2063
                    systemName: "arrow.counterclockwise",
2064
                    tone: .reversible,
2065
                    confirmationTitle: "Reset session trim?",
2066
                    confirmationButtonTitle: "Reset trim",
2067
                    handler: {
2068
                        onSetTrim(nil, nil)
2069
                    }
Bogdan Timofte authored a month ago
2070
                ),
2071
                exportAction: sessionCSVExportAction
Bogdan Timofte authored a month ago
2072
            )
2073
        }
2074
    }
2075

            
Bogdan Timofte authored a month ago
2076
    private var sessionCSVExportAction: MeasurementChartExportAction {
2077
        MeasurementChartExportAction(
2078
            title: "Export CSV",
2079
            shortTitle: "CSV",
2080
            systemName: "square.and.arrow.up",
2081
            tone: .reversible,
2082
            fileName: sessionCSVFileName,
2083
            content: sessionCSVContent
2084
        )
2085
    }
2086

            
2087
    private func sessionCSVFileName(for range: ClosedRange<Date>) -> String {
2088
        let formatter = DateFormatter()
2089
        formatter.locale = Locale(identifier: "en_US_POSIX")
2090
        formatter.timeZone = .current
2091
        formatter.dateFormat = "yyyyMMdd-HHmmss"
2092

            
2093
        return [
2094
            "charge-session",
2095
            formatter.string(from: range.lowerBound),
2096
            formatter.string(from: range.upperBound)
2097
        ].joined(separator: "-")
2098
    }
2099

            
2100
    private func sessionCSVContent(for range: ClosedRange<Date>) -> String {
2101
        let samples = session.aggregatedSamples
2102
            .filter { range.contains($0.timestamp) }
2103
            .sorted { lhs, rhs in
2104
                if lhs.bucketIndex != rhs.bucketIndex {
2105
                    return lhs.bucketIndex < rhs.bucketIndex
2106
                }
2107
                return lhs.timestamp < rhs.timestamp
2108
            }
2109

            
2110
        let timestampFormatter = ISO8601DateFormatter()
2111
        timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
2112

            
2113
        var rows: [[String]] = [
2114
            [
2115
                "Timestamp",
2116
                "Elapsed Seconds",
2117
                "Voltage (V)",
2118
                "Current (A)",
2119
                "Power (W)",
2120
                "Session Energy (Wh)",
2121
                "Interval Energy (Wh)",
2122
                "Battery (%)",
2123
                "Sample Count"
2124
            ]
2125
        ]
2126

            
2127
        let intervalEnergyBaseline = samples.first?.measuredEnergyWh ?? 0
2128
        for sample in samples {
2129
            rows.append([
2130
                timestampFormatter.string(from: sample.timestamp),
2131
                formattedCSVNumber(sample.timestamp.timeIntervalSince(session.startedAt), fractionDigits: 3),
2132
                formattedCSVNumber(sample.averageVoltageVolts, fractionDigits: 6),
2133
                formattedCSVNumber(sample.averageCurrentAmps, fractionDigits: 6),
2134
                formattedCSVNumber(sample.averagePowerWatts, fractionDigits: 6),
2135
                formattedCSVNumber(sample.measuredEnergyWh, fractionDigits: 6),
2136
                formattedCSVNumber(max(sample.measuredEnergyWh - intervalEnergyBaseline, 0), fractionDigits: 6),
2137
                formattedCSVNumber(sample.estimatedBatteryPercent, fractionDigits: 3),
2138
                "\(sample.sampleCount)"
2139
            ])
2140
        }
2141

            
2142
        return rows
2143
            .map { row in row.map(escapedCSVField).joined(separator: ",") }
2144
            .joined(separator: "\n")
2145
    }
2146

            
2147
    private func formattedCSVNumber(_ value: Double?, fractionDigits: Int) -> String {
2148
        guard let value, value.isFinite else { return "" }
2149
        return String(
2150
            format: "%.\(fractionDigits)f",
2151
            locale: Locale(identifier: "en_US_POSIX"),
2152
            value
2153
        )
2154
    }
2155

            
2156
    private func escapedCSVField(_ field: String) -> String {
2157
        let mustQuote = field.contains(",") || field.contains("\"") || field.contains("\n")
2158
        guard mustQuote else { return field }
2159
        return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
2160
    }
2161

            
Bogdan Timofte authored a month ago
2162
    private func restoreStoredMeasurementsIfNeeded() {
2163
        guard monitoringMeter == nil || session.status.isOpen == false else {
2164
            return
2165
        }
2166
        storedMeasurements.resetSeries()
2167
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
2168
            from: session,
2169
            replacingLiveBufferIfNeeded: true
2170
        )
2171
    }
2172

            
2173
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
2174
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
2175
    }
2176

            
2177
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
2178
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
2179
        let end = normalizedEnd(range.upperBound)
2180
        onStopWithTrim(start, end)
2181
    }
2182

            
2183
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
2184
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
2185
    }
2186

            
2187
    private func normalizedStart(_ date: Date) -> Date? {
2188
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
2189
    }
2190

            
2191
    private func normalizedEnd(_ date: Date) -> Date? {
2192
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
2193
    }
2194
}
2195

            
2196
private struct ChargeSessionStopRequest: Identifiable {
2197
    let sessionID: UUID
2198
    let title: String
2199
    let confirmTitle: String
2200
    let explanation: String
2201
    let appliesTrim: Bool
2202
    let trimStart: Date?
2203
    let trimEnd: Date?
2204

            
2205
    var id: String {
2206
        [
2207
            sessionID.uuidString,
2208
            title,
2209
            trimStart?.timeIntervalSince1970.description ?? "nil",
2210
            trimEnd?.timeIntervalSince1970.description ?? "nil"
2211
        ].joined(separator: "-")
2212
    }
2213
}
2214

            
2215
private extension View {
2216
    func monitoringActionStyle(tint: Color) -> some View {
2217
        frame(maxWidth: .infinity)
2218
            .padding(.vertical, 10)
2219
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
2220
            .buttonStyle(.plain)
2221
    }
2222

            
2223
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
2224
        frame(maxWidth: .infinity)
2225
            .padding(.vertical, 9)
2226
            .meterCard(
2227
                tint: tint,
2228
                fillOpacity: isProminent ? 0.22 : 0.10,
2229
                strokeOpacity: isProminent ? 0.32 : 0.14,
2230
                cornerRadius: 14
2231
            )
2232
            .buttonStyle(.plain)
2233
    }
2234
}