Newer Older
2130 lines | 83.664kb
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
                            }
617
                            Text(
618
                                "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
619
                            )
620
                            .font(.caption2)
621
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
622
                        }
623
                    }
Bogdan Timofte authored a month ago
624

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

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

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

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

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

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

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

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

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

            
742
                Spacer()
743

            
744
                VStack(alignment: .trailing, spacing: 2) {
745
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
746
                        .font(.callout.weight(.bold))
747
                        .foregroundColor(.orange)
748
                        .monospacedDigit()
749
                    Text("est. capacity")
750
                        .font(.caption2)
751
                        .foregroundColor(.secondary)
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
        }
1807
        let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
1808
        return etaText(
1809
            rateWhPerSec: rateWhPerSec,
1810
            remainingWh: max(targetEnergyWh - displayedEnergyWh, 0),
1811
            isRelevant: true
1812
        )
1813
    }
1814

            
1815
    private func formatETA(_ seconds: TimeInterval) -> String {
1816
        let totalMinutes = Int(seconds / 60)
1817
        if totalMinutes < 60 { return "\(totalMinutes)m" }
1818
        let hours = totalMinutes / 60
1819
        let minutes = totalMinutes % 60
1820
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
1821
    }
1822

            
1823
    private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
1824
        switch session.status {
1825
        case .active:
1826
            return .red
1827
        case .paused:
1828
            return .orange
1829
        case .completed:
1830
            return .green
1831
        case .abandoned:
1832
            return .secondary
1833
        }
1834
    }
1835

            
1836
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
Bogdan Timofte authored a month ago
1837
        nil
Bogdan Timofte authored a month ago
1838
    }
1839

            
1840
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
1841
        guard session.chargingTransportMode == .wireless else {
1842
            return nil
1843
        }
1844

            
1845
        var components: [String] = []
1846
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
1847
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
1848
        }
1849
        if session.usesEstimatedWirelessEfficiency {
1850
            components.append("Estimated from wired baseline and checkpoints")
1851
        }
1852
        if session.shouldWarnAboutLowWirelessEfficiency {
1853
            components.append("Low wireless efficiency, so capacity confidence is reduced")
1854
        }
1855

            
1856
        return components.isEmpty ? nil : components.joined(separator: " - ")
1857
    }
1858

            
1859
    private func statusTint(for session: ChargeSessionSummary) -> Color {
1860
        switch session.status {
1861
        case .active:
1862
            return .green
1863
        case .paused:
1864
            return .orange
1865
        case .completed:
1866
            return .teal
1867
        case .abandoned:
1868
            return .secondary
1869
        }
1870
    }
1871
}
1872

            
1873
enum ChargeSessionChartControlMode {
1874
    case none
1875
    case activeMonitoring
1876
    case closed
1877
}
1878

            
1879
struct ChargeSessionChartCardView: View {
1880
    let session: ChargeSessionSummary
1881
    let monitoringMeter: Meter?
Bogdan Timofte authored a month ago
1882
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
1883
    let controlMode: ChargeSessionChartControlMode
1884
    let onSetTrim: (Date?, Date?) -> Void
1885
    let onStopWithTrim: (Date?, Date?) -> Void
Bogdan Timofte authored a month ago
1886
    let onCommitTrim: (() -> Void)?
Bogdan Timofte authored a month ago
1887

            
1888
    @StateObject private var storedMeasurements = Measurements()
1889

            
1890
    private var chartMeasurements: Measurements {
1891
        if let monitoringMeter,
1892
           session.status.isOpen,
1893
           session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
1894
            return monitoringMeter.chargeRecordMeasurements
1895
        }
1896
        return storedMeasurements
1897
    }
1898

            
1899
    private var fullTimeRange: ClosedRange<Date> {
1900
        let start = session.startedAt
1901
        let end = max(session.endedAt ?? session.lastObservedAt, start)
1902
        return start...end
1903
    }
1904

            
1905
    private var fixedTimeRange: ClosedRange<Date>? {
1906
        if monitoringMeter != nil && session.status.isOpen {
1907
            return nil
1908
        }
1909
        return session.effectiveTimeRange
1910
    }
1911

            
1912
    private var liveTrimBounds: (lower: Date?, upper: Date?) {
1913
        guard monitoringMeter != nil && session.status.isOpen else {
1914
            return (nil, nil)
1915
        }
1916
        return (session.trimStart, session.trimEnd)
1917
    }
1918

            
1919
    private var showsRangeSelector: Bool {
1920
        controlMode != .none && !session.aggregatedSamples.isEmpty
1921
    }
1922

            
1923
    var body: some View {
1924
        VStack(alignment: .leading, spacing: 12) {
1925
            HStack(spacing: 8) {
1926
                Image(systemName: "chart.xyaxis.line")
1927
                    .foregroundColor(.blue)
1928
                Text("Session Chart")
1929
                    .font(.headline)
1930
                ContextInfoButton(
1931
                    title: "Session Chart",
1932
                    message: chartInfoMessage
1933
                )
1934
                Spacer(minLength: 0)
1935
            }
1936

            
1937
            MeasurementChartView(
1938
                timeRange: fixedTimeRange,
1939
                timeRangeLowerBound: liveTrimBounds.lower,
1940
                timeRangeUpperBound: liveTrimBounds.upper,
1941
                showsRangeSelector: showsRangeSelector,
1942
                rebasesEnergyToVisibleRangeStart: true,
1943
                extendsTimelineToPresent: false,
1944
                showsTemperatureSeries: false,
Bogdan Timofte authored a month ago
1945
                showsBatteryPercentSeries: shouldShowBatteryPercentSeries,
1946
                batteryCheckpoints: session.checkpoints,
1947
                batteryPercentPoints: batteryPercentPoints,
Bogdan Timofte authored a month ago
1948
                rangeSelectorConfiguration: rangeSelectorConfiguration
1949
            )
1950
            .environmentObject(chartMeasurements)
1951
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
1952

            
1953
            if let onCommitTrim {
1954
                Divider()
1955

            
1956
                HStack(alignment: .center, spacing: 10) {
1957
                    Label("Save trim permanently", systemImage: "internaldrive")
1958
                        .font(.caption.weight(.semibold))
1959
                        .foregroundColor(.secondary)
1960

            
1961
                    Spacer(minLength: 0)
1962

            
1963
                    Button {
1964
                        onCommitTrim()
1965
                    } label: {
1966
                        Label("Save Trim", systemImage: "checkmark.seal")
1967
                            .font(.caption.weight(.semibold))
1968
                    }
1969
                    .buttonStyle(.borderedProminent)
1970
                    .controlSize(.small)
1971
                    .tint(.red)
1972
                }
1973
            }
Bogdan Timofte authored a month ago
1974
        }
1975
        .padding(18)
1976
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1977
        .onAppear(perform: restoreStoredMeasurementsIfNeeded)
1978
        .onChange(of: session.id) { _ in
1979
            restoreStoredMeasurementsIfNeeded()
1980
        }
1981
        .onChange(of: session.aggregatedSamples.count) { _ in
1982
            restoreStoredMeasurementsIfNeeded()
1983
        }
Bogdan Timofte authored a month ago
1984
        .onChange(of: session.checkpoints.count) { _ in
1985
            restoreStoredMeasurementsIfNeeded()
1986
        }
Bogdan Timofte authored a month ago
1987
    }
1988

            
1989
    private var chartInfoMessage: String {
1990
        if monitoringMeter != nil && session.status.isOpen {
1991
            return "This chart combines the persisted session curve with current live data from this meter."
1992
        }
1993

            
1994
        return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1995
    }
1996

            
Bogdan Timofte authored a month ago
1997
    private var shouldShowBatteryPercentSeries: Bool {
1998
        !batteryPercentPoints.isEmpty
1999
    }
2000

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

            
2058
    private func restoreStoredMeasurementsIfNeeded() {
2059
        guard monitoringMeter == nil || session.status.isOpen == false else {
2060
            return
2061
        }
2062
        storedMeasurements.resetSeries()
2063
        _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
2064
            from: session,
2065
            replacingLiveBufferIfNeeded: true
2066
        )
2067
    }
2068

            
2069
    private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
2070
        onSetTrim(normalizedStart(range.lowerBound), session.trimEnd)
2071
    }
2072

            
2073
    private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
2074
        let start = session.trimStart ?? normalizedStart(range.lowerBound)
2075
        let end = normalizedEnd(range.upperBound)
2076
        onStopWithTrim(start, end)
2077
    }
2078

            
2079
    private func applyClosedTrim(_ range: ClosedRange<Date>) {
2080
        onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound))
2081
    }
2082

            
2083
    private func normalizedStart(_ date: Date) -> Date? {
2084
        date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date
2085
    }
2086

            
2087
    private func normalizedEnd(_ date: Date) -> Date? {
2088
        fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date
2089
    }
2090
}
2091

            
2092
private struct ChargeSessionStopRequest: Identifiable {
2093
    let sessionID: UUID
2094
    let title: String
2095
    let confirmTitle: String
2096
    let explanation: String
2097
    let appliesTrim: Bool
2098
    let trimStart: Date?
2099
    let trimEnd: Date?
2100

            
2101
    var id: String {
2102
        [
2103
            sessionID.uuidString,
2104
            title,
2105
            trimStart?.timeIntervalSince1970.description ?? "nil",
2106
            trimEnd?.timeIntervalSince1970.description ?? "nil"
2107
        ].joined(separator: "-")
2108
    }
2109
}
2110

            
2111
private extension View {
2112
    func monitoringActionStyle(tint: Color) -> some View {
2113
        frame(maxWidth: .infinity)
2114
            .padding(.vertical, 10)
2115
            .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
2116
            .buttonStyle(.plain)
2117
    }
2118

            
2119
    func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
2120
        frame(maxWidth: .infinity)
2121
            .padding(.vertical, 9)
2122
            .meterCard(
2123
                tint: tint,
2124
                fillOpacity: isProminent ? 0.22 : 0.10,
2125
                strokeOpacity: isProminent ? 0.32 : 0.14,
2126
                cornerRadius: 14
2127
            )
2128
            .buttonStyle(.plain)
2129
    }
2130
}