Newer Older
2228 lines | 87.624kb
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 {
597
                                overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
598
                            }
Bogdan Timofte authored a month ago
599
                            if let v = session.endBatteryPercent {
600
                                overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
601
                            }
602
                        }
Bogdan Timofte authored a month ago
603
                        if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
604
                            HStack(alignment: .top, spacing: 12) {
605
                                if let v = session.batteryDeltaPercent {
606
                                    overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%")
607
                                }
608
                                if let v = session.targetBatteryPercent {
609
                                    overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%")
610
                                }
611
                            }
612
                        }
613
                        if let batteryPrediction {
614
                            HStack(alignment: .top, spacing: 12) {
615
                                overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
616
                            }
Bogdan Timofte authored a month ago
617
                            Text(batteryPredictionExplanation(batteryPrediction))
Bogdan Timofte authored a month ago
618
                            .font(.caption2)
619
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
620
                        }
621
                    }
Bogdan Timofte authored a month ago
622

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

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

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

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

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

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

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

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

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

            
740
                Spacer()
741

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

            
755
            batteryProgressBar(
756
                percent: percent,
757
                startPercent: session.startBatteryPercent,
758
                targetPercent: session.targetBatteryPercent
759
            )
760

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

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

            
775
                Spacer()
776

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
937
        return HStack(alignment: .center, spacing: 8) {
938
            Image(systemName: "bell.badge")
939
                .foregroundColor(.indigo)
940
                .font(.subheadline)
941

            
942
            Text("Notify at")
943
                .font(.subheadline.weight(.semibold))
944

            
945
            Spacer(minLength: 8)
946

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

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

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

            
985
            Text("%")
986
                .font(.subheadline)
987
                .foregroundColor(.secondary)
988

            
989
            if draftBelowPrediction, let predictedPercent {
990
                predictionWarningButton(predictedPercent: predictedPercent)
991
            }
992

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

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

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

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

            
1038
                if savedBelowPrediction, let predictedPercent {
1039
                    predictionWarningButton(predictedPercent: predictedPercent)
1040
                }
1041

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

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

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

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

            
1094
            Button("Terminate Session") {
1095
                beginStopConfirmation(for: session)
1096
            }
1097
            .monitoringActionStyle(tint: .red)
1098
        }
1099
    }
1100

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

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

            
1124
            finalCheckpointPicker(session)
1125

            
1126
            if finalCheckpointMode == .custom {
1127
                customFinalCheckpointRow
1128
            }
1129

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

            
1147
            HStack(spacing: 8) {
1148
                Button("Discard") {
1149
                    discardSession(session)
1150
                }
1151
                .monitoringPanelActionStyle(tint: .secondary)
1152

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

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

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

            
1210
    private var customFinalCheckpointRow: some View {
1211
        let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1212
            || parsedFinalCheckpoint == nil
1213

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

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

            
1232
            Text("%").foregroundColor(.secondary)
1233

            
1234
            Text("required")
1235
                .font(.caption2.weight(.semibold))
1236
                .foregroundColor(isInvalid ? .red : .secondary)
1237

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

            
1245
            Spacer()
1246
        }
1247
    }
1248

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

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

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

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

            
1292
                Spacer(minLength: 0)
1293

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

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

            
1331
    private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
1332
        !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil
1333
    }
1334

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

            
1368
    private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
1369
        if hasMonitoringControls {
1370
            return .activeMonitoring
1371
        }
1372

            
1373
        if session.status.isOpen == false {
1374
            return .closed
1375
        }
1376

            
1377
        return .none
1378
    }
1379

            
1380
    private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
1381
        _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end)
1382
        trimBannerDismissedForSessionID = sessionID
1383
    }
1384

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

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

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

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

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

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

            
1449
        var points: [Measurements.Measurement.Point] = []
1450
        var previousCandidate: BatteryPercentCandidate?
1451

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

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

            
1475
        return points
1476
    }
1477

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

            
1488
        var coalesced: [BatteryPercentCandidate] = []
1489

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

            
1501
        return coalesced
1502
    }
1503

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

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

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

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

            
1558
    private var resolvedFinalCheckpoint: Double? {
1559
        switch finalCheckpointMode {
1560
        case .full:   return 100
1561
        case .skip:   return nil
1562
        case .custom: return parsedFinalCheckpoint
1563
        }
1564
    }
1565

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

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

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

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

            
1599
    private func saveDisabledReason(
1600
        session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1601
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1602
    ) -> String? {
1603
        if finalCheckpointMode == .custom {
1604
            let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines)
1605
            if trimmed.isEmpty {
1606
                return "Enter the final battery percentage or choose Skip."
1607
            }
1608
            if parsedFinalCheckpoint == nil {
1609
                return "Final battery percentage must be between 0 and 100."
1610
            }
1611
        }
1612

            
1613
        guard hasSavableChargeData(
1614
            session: session,
Bogdan Timofte authored a month ago
1615
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1616
        ) else {
1617
            return "This session has no charging data to save. Discard it instead."
1618
        }
1619

            
1620
        return nil
1621
    }
1622

            
1623
    private func stopSession(
1624
        _ session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1625
        displayedEnergyWh: Double
Bogdan Timofte authored a month ago
1626
    ) {
1627
        stopFailureMessage = nil
1628

            
1629
        if let saveDisabledReason = saveDisabledReason(
1630
            session: session,
Bogdan Timofte authored a month ago
1631
            displayedEnergyWh: displayedEnergyWh
Bogdan Timofte authored a month ago
1632
        ) {
1633
            stopFailureMessage = saveDisabledReason
1634
            return
1635
        }
1636

            
1637
        let didSave = appData.stopChargeSession(
1638
            sessionID: session.id,
1639
            finalBatteryPercent: resolvedFinalCheckpoint,
1640
            from: liveMonitoringMeter
1641
        )
1642
        if didSave {
1643
            resetStopConfirmation()
1644
        } else {
1645
            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."
1646
        }
1647
    }
1648

            
1649
    private func beginStopConfirmation(for session: ChargeSessionSummary) {
1650
        finalCheckpointMode = .skip
1651
        finalCheckpointText = ""
1652
        stopFailureMessage = nil
1653
        showingStopConfirm = true
1654
    }
1655

            
1656
    private func discardSession(_ session: ChargeSessionSummary) {
1657
        _ = appData.deleteChargeSession(sessionID: session.id)
1658
        resetStopConfirmation()
1659
    }
1660

            
1661
    private func resetStopConfirmation() {
1662
        showingStopConfirm = false
1663
        finalCheckpointText = ""
1664
        finalCheckpointMode = .skip
1665
        stopFailureMessage = nil
1666
    }
1667

            
1668
    private func syncMonitoringRestore() {
1669
        guard let session,
1670
              session.status.isOpen,
1671
              let liveMonitoringMeter,
1672
              session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
1673
            return
1674
        }
1675
        liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session)
1676
    }
1677

            
1678
    private func runTrimDetection() {
1679
        guard hasMonitoringControls,
1680
              let session,
1681
              session.isTrimmed == false,
1682
              !session.aggregatedSamples.isEmpty else {
1683
            detectedTrimWindow = nil
1684
            return
1685
        }
1686

            
1687
        let sessionEnd = session.endedAt ?? session.lastObservedAt
1688
        detectedTrimWindow = ChargingWindowDetector.detect(
1689
            samples: session.aggregatedSamples,
1690
            sessionStart: session.startedAt,
1691
            sessionEnd: sessionEnd
1692
        )
1693
    }
1694

            
1695
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1696
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1697
        guard session.isTrimmed == false else { return storedEnergyWh }
1698
        guard session.status.isOpen else { return storedEnergyWh }
1699
        guard let liveMonitoringMeter else { return storedEnergyWh }
1700
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
1701
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1702
            return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0))
1703
        }
1704
        return storedEnergyWh
1705
    }
1706

            
1707
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1708
        let storedDuration = max(session.effectiveDuration, 0)
1709
        guard session.isTrimmed == false else { return storedDuration }
1710
        guard session.status.isOpen else { return storedDuration }
1711
        guard let liveMonitoringMeter else { return storedDuration }
1712
        guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
1713
        return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0))
1714
    }
1715

            
1716
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1717
        let displayedDuration = displayedSessionDuration(for: session)
1718
        let formatter = DateComponentsFormatter()
1719
        formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1720
        formatter.unitsStyle = .abbreviated
1721
        formatter.zeroFormattingBehavior = .dropAll
1722
        return formatter.string(from: displayedDuration) ?? "0m"
1723
    }
1724

            
1725
    private func formatDuration(_ duration: TimeInterval) -> String {
1726
        let totalSeconds = Int(duration.rounded(.down))
1727
        let hours = totalSeconds / 3600
1728
        let minutes = (totalSeconds % 3600) / 60
1729
        let seconds = totalSeconds % 60
1730
        if hours > 0 {
1731
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1732
        }
1733
        return String(format: "%02d:%02d", minutes, seconds)
1734
    }
1735

            
1736
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
1737
        if session.autoStopEnabled == false {
1738
            return "Manual"
1739
        }
1740

            
1741
        if let sessionWarning = sessionWarning(for: session),
1742
           sessionWarning.contains("idle-current") {
1743
            return "Blocked by charger setup"
1744
        }
1745

            
1746
        if session.stopThresholdAmps > 0 {
1747
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1748
        }
1749

            
1750
        return "Learning"
1751
    }
1752

            
1753
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1754
        if session.autoStopEnabled == false {
1755
            return "Manual"
1756
        }
1757
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1758
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1759
        }
1760
        if session.stopThresholdAmps > 0 {
1761
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1762
        }
1763
        return "Learning"
1764
    }
1765

            
1766
    private func shouldShowChargingTransport(
1767
        for session: ChargeSessionSummary,
1768
        chargedDevice: ChargedDeviceSummary
1769
    ) -> Bool {
1770
        chargedDevice.supportedChargingModes.count > 1
1771
            || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1772
    }
1773

            
1774
    private func shouldShowChargingState(
1775
        for session: ChargeSessionSummary,
1776
        chargedDevice: ChargedDeviceSummary
1777
    ) -> Bool {
1778
        chargedDevice.supportedChargingStateModes.count > 1
1779
            || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1780
    }
1781

            
1782
    private func batteryColor(for percent: Double) -> Color {
1783
        if percent >= 75 { return .green }
1784
        if percent >= 35 { return .orange }
1785
        return .red
1786
    }
1787

            
1788
    private func etaText(
1789
        rateWhPerSec: Double?,
1790
        remainingWh: Double,
1791
        isRelevant: Bool
1792
    ) -> String? {
1793
        guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
1794
        let seconds = remainingWh / rateWhPerSec
1795
        return seconds > 120 ? formatETA(seconds) : nil
1796
    }
1797

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

            
Bogdan Timofte authored a month ago
1817
    private func batteryPredictionExplanation(_ prediction: BatteryLevelPrediction) -> String {
1818
        let anchor = "Anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%"
1819
        guard let estimatedCapacityWh = prediction.estimatedCapacityWh else {
1820
            return "\(anchor)."
1821
        }
1822
        return "\(anchor) using \(estimatedCapacityWh.format(decimalDigits: 2)) Wh \(prediction.basis.explanatoryLabel)."
1823
    }
1824

            
Bogdan Timofte authored a month ago
1825
    private func formatETA(_ seconds: TimeInterval) -> String {
1826
        let totalMinutes = Int(seconds / 60)
1827
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1828
        let hours = totalMinutes / 60
1829
        let minutes = totalMinutes % 60
1830
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1831
    }
1832

            
1833
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1834
        switch session.status {
1835
        case .active:
1836
            return .red
1837
        case .paused:
1838
            return .orange
1839
        case .completed:
1840
            return .green
1841
        case .abandoned:
1842
            return .secondary
1843
        }
1844
    }
1845

            
1846
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1847
        nil
Bogdan Timofte authored a month ago
1848
    }
1849

            
1850
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1851
        guard session.chargingTransportMode == .wireless else {
1852
            return nil
1853
        }
1854

            
1855
        var components: [String] = []
1856
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1857
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1858
        }
1859
        if session.usesEstimatedWirelessEfficiency {
1860
            components.append("Estimated from wired baseline and checkpoints")
1861
        }
1862
        if session.shouldWarnAboutLowWirelessEfficiency {
1863
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1864
        }
1865

            
1866
        return components.isEmpty ? nil : components.joined(separator: " - ")
1867
    }
1868

            
1869
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1870
        switch session.status {
1871
        case .active:
1872
            return .green
1873
        case .paused:
1874
            return .orange
1875
        case .completed:
1876
            return .teal
1877
        case .abandoned:
1878
            return .secondary
1879
        }
1880
    }
1881
}
1882

            
1883
enum ChargeSessionChartControlMode {
1884
    case none
1885
    case activeMonitoring
1886
    case closed
1887
}
1888

            
1889
struct ChargeSessionChartCardView: View {
1890
    let session: ChargeSessionSummary
1891
    let monitoringMeter: Meter?
Bogdan Timofte authored a month ago
1892
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
1893
    let controlMode: ChargeSessionChartControlMode
1894
    let onSetTrim: (Date?, Date?) -> Void
1895
    let onStopWithTrim: (Date?, Date?) -> Void
Bogdan Timofte authored a month ago
1896
    let onCommitTrim: (() -> Void)?
Bogdan Timofte authored a month ago
1897

            
1898
    @StateObject private var storedMeasurements = Measurements()
1899

            
1900
    private var chartMeasurements: Measurements {
1901
        if let monitoringMeter,
1902
           session.status.isOpen,
1903
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1904
            return monitoringMeter.chargeRecordMeasurements
1905
        }
1906
        return storedMeasurements
1907
    }
1908

            
1909
    private var fullTimeRange: ClosedRange<Date> {
1910
        let start = session.startedAt
1911
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1912
        return start...end
1913
    }
1914

            
1915
    private var fixedTimeRange: ClosedRange<Date>? {
1916
        if monitoringMeter != nil && session.status.isOpen {
1917
            return nil
1918
        }
1919
        return session.effectiveTimeRange
1920
    }
1921

            
1922
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1923
        guard monitoringMeter != nil && session.status.isOpen else {
1924
            return (nil, nil)
1925
        }
1926
        return (session.trimStart, session.trimEnd)
1927
    }
1928

            
1929
    private var showsRangeSelector: Bool {
1930
        controlMode != .none && !session.aggregatedSamples.isEmpty
1931
    }
1932

            
1933
    var body: some View {
1934
        VStack(alignment: .leading, spacing: 12) {
1935
            HStack(spacing: 8) {
1936
                Image(systemName: "chart.xyaxis.line")
1937
                    .foregroundColor(.blue)
1938
                Text("Session Chart")
1939
                    .font(.headline)
1940
                ContextInfoButton(
1941
                    title: "Session Chart",
1942
                    message: chartInfoMessage
1943
                )
1944
                Spacer(minLength: 0)
1945
            }
1946

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

            
1963
            if let onCommitTrim {
1964
                Divider()
1965

            
1966
                HStack(alignment: .center, spacing: 10) {
1967
                    Label("Save trim permanently", systemImage: "internaldrive")
1968
                        .font(.caption.weight(.semibold))
1969
                        .foregroundColor(.secondary)
1970

            
1971
                    Spacer(minLength: 0)
1972

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

            
1999
    private var chartInfoMessage: String {
2000
        if monitoringMeter != nil && session.status.isOpen {
2001
            return "This chart combines the persisted session curve with current live data from this meter."
2002
        }
2003

            
2004
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
2005
    }
2006

            
Bogdan Timofte authored a month ago
2007
    private var shouldShowBatteryPercentSeries: Bool {
2008
        !batteryPercentPoints.isEmpty
2009
    }
2010

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

            
Bogdan Timofte authored a month ago
2070
    private var sessionCSVExportAction: MeasurementChartExportAction {
2071
        MeasurementChartExportAction(
2072
            title: "Export CSV",
2073
            shortTitle: "CSV",
2074
            systemName: "square.and.arrow.up",
2075
            tone: .reversible,
2076
            fileName: sessionCSVFileName,
2077
            content: sessionCSVContent
2078
        )
2079
    }
2080

            
2081
    private func sessionCSVFileName(for range: ClosedRange<Date>) -> String {
2082
        let formatter = DateFormatter()
2083
        formatter.locale = Locale(identifier: "en_US_POSIX")
2084
        formatter.timeZone = .current
2085
        formatter.dateFormat = "yyyyMMdd-HHmmss"
2086

            
2087
        return [
2088
            "charge-session",
2089
            formatter.string(from: range.lowerBound),
2090
            formatter.string(from: range.upperBound)
2091
        ].joined(separator: "-")
2092
    }
2093

            
2094
    private func sessionCSVContent(for range: ClosedRange<Date>) -> String {
2095
        let samples = session.aggregatedSamples
2096
            .filter { range.contains($0.timestamp) }
2097
            .sorted { lhs, rhs in
2098
                if lhs.bucketIndex != rhs.bucketIndex {
2099
                    return lhs.bucketIndex < rhs.bucketIndex
2100
                }
2101
                return lhs.timestamp < rhs.timestamp
2102
            }
2103

            
2104
        let timestampFormatter = ISO8601DateFormatter()
2105
        timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
2106

            
2107
        var rows: [[String]] = [
2108
            [
2109
                "Timestamp",
2110
                "Elapsed Seconds",
2111
                "Voltage (V)",
2112
                "Current (A)",
2113
                "Power (W)",
2114
                "Session Energy (Wh)",
2115
                "Interval Energy (Wh)",
2116
                "Battery (%)",
2117
                "Sample Count"
2118
            ]
2119
        ]
2120

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

            
2136
        return rows
2137
            .map { row in row.map(escapedCSVField).joined(separator: ",") }
2138
            .joined(separator: "\n")
2139
    }
2140

            
2141
    private func formattedCSVNumber(_ value: Double?, fractionDigits: Int) -> String {
2142
        guard let value, value.isFinite else { return "" }
2143
        return String(
2144
            format: "%.\(fractionDigits)f",
2145
            locale: Locale(identifier: "en_US_POSIX"),
2146
            value
2147
        )
2148
    }
2149

            
2150
    private func escapedCSVField(_ field: String) -> String {
2151
        let mustQuote = field.contains(",") || field.contains("\"") || field.contains("\n")
2152
        guard mustQuote else { return field }
2153
        return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
2154
    }
2155

            
Bogdan Timofte authored a month ago
2156
    private func restoreStoredMeasurementsIfNeeded() {
2157
        guard monitoringMeter == nil || session.status.isOpen == false else {
2158
            return
2159
        }
2160
        storedMeasurements.resetSeries()
2161
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
2162
            from: session,
2163
            replacingLiveBufferIfNeeded: true
2164
        )
2165
    }
2166

            
2167
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
2168
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
2169
    }
2170

            
2171
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
2172
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
2173
        let end = normalizedEnd(range.upperBound)
2174
        onStopWithTrim(start, end)
2175
    }
2176

            
2177
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
2178
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
2179
    }
2180

            
2181
    private func normalizedStart(_ date: Date) -> Date? {
2182
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
2183
    }
2184

            
2185
    private func normalizedEnd(_ date: Date) -> Date? {
2186
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
2187
    }
2188
}
2189

            
2190
private struct ChargeSessionStopRequest: Identifiable {
2191
    let sessionID: UUID
2192
    let title: String
2193
    let confirmTitle: String
2194
    let explanation: String
2195
    let appliesTrim: Bool
2196
    let trimStart: Date?
2197
    let trimEnd: Date?
2198

            
2199
    var id: String {
2200
        [
2201
            sessionID.uuidString,
2202
            title,
2203
            trimStart?.timeIntervalSince1970.description ?? "nil",
2204
            trimEnd?.timeIntervalSince1970.description ?? "nil"
2205
        ].joined(separator: "-")
2206
    }
2207
}
2208

            
2209
private extension View {
2210
    func monitoringActionStyle(tint: Color) -> some View {
2211
        frame(maxWidth: .infinity)
2212
            .padding(.vertical, 10)
2213
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
2214
            .buttonStyle(.plain)
2215
    }
2216

            
2217
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
2218
        frame(maxWidth: .infinity)
2219
            .padding(.vertical, 9)
2220
            .meterCard(
2221
                tint: tint,
2222
                fillOpacity: isProminent ? 0.22 : 0.10,
2223
                strokeOpacity: isProminent ? 0.32 : 0.14,
2224
                cornerRadius: 14
2225
            )
2226
            .buttonStyle(.plain)
2227
    }
2228
}