USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1739 lines | 71.994kb
Bogdan Timofte authored a month ago
1
//
2
//  MeterChargeRecordTabView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7

            
Bogdan Timofte authored a month ago
8
struct MeterChargeRecordTabView: View, Equatable {
9
    static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
10
        true
11
    }
12

            
Bogdan Timofte authored a month ago
13
    var body: some View {
Bogdan Timofte authored a month ago
14
        MeterChargeRecordContentView()
Bogdan Timofte authored a month ago
15
    }
16
}
17

            
Bogdan Timofte authored a month ago
18
private struct SessionChartWidthPreferenceKey: PreferenceKey {
19
    static let defaultValue: CGFloat = 760
20

            
21
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
22
        let next = nextValue()
23
        if next > 0 {
24
            value = next
25
        }
26
    }
27
}
28

            
Bogdan Timofte authored a month ago
29
struct MeterChargeRecordContentView: View {
Bogdan Timofte authored a month ago
30
    private struct SessionMetricRow {
31
        let label: String
32
        let value: String
33
    }
34

            
Bogdan Timofte authored a month ago
35
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
36
        case known
37
        case unknown
38
        case flat
39

            
40
        var id: String { rawValue }
41

            
42
        var title: String {
43
            switch self {
Bogdan Timofte authored a month ago
44
            case .known:   return "Known"
45
            case .unknown: return "Unknown"
46
            case .flat:    return "Flat"
Bogdan Timofte authored a month ago
47
            }
48
        }
49
    }
50

            
Bogdan Timofte authored a month ago
51
    private enum ActiveMode: Hashable {
52
        case chargeSession
53
        case standbyPower
54
    }
Bogdan Timofte authored a month ago
55

            
Bogdan Timofte authored a month ago
56
    private enum FinalCheckpoint: Hashable {
57
        case full
58
        case skip
59
        case custom
60

            
61
        var label: String {
62
            switch self {
63
            case .full:   return "Full"
64
            case .skip:   return "Skip"
65
            case .custom: return "Other %"
66
            }
67
        }
68

            
69
        var icon: String {
70
            switch self {
71
            case .full:   return "battery.100percent"
72
            case .skip:   return "minus.circle"
73
            case .custom: return "pencil"
74
            }
75
        }
76
    }
77

            
Bogdan Timofte authored a month ago
78
    private enum SessionStartRequirement: Identifiable {
79
        case existingSession
80
        case device
81
        case chargingType
82
        case chargingMode
83
        case charger
84
        case initialCheckpointEmpty
85
        case initialCheckpointInvalid
86

            
87
        var id: String {
88
            switch self {
Bogdan Timofte authored a month ago
89
            case .existingSession:         return "existing-session"
90
            case .device:                  return "device"
91
            case .chargingType:            return "charging-type"
92
            case .chargingMode:            return "charging-mode"
93
            case .charger:                 return "charger"
94
            case .initialCheckpointEmpty:  return "initial-checkpoint-empty"
95
            case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
Bogdan Timofte authored a month ago
96
            }
97
        }
98

            
99
        var message: String {
100
            switch self {
Bogdan Timofte authored a month ago
101
            case .existingSession:          return "Stop or pause the current session before starting another one."
102
            case .device:                   return "Select the device that is charging."
103
            case .chargingType:             return "Choose the charging type for this session."
104
            case .chargingMode:             return "Choose whether the device is on or off for this session."
105
            case .charger:                  return "Select the wireless charger used in this session."
106
            case .initialCheckpointEmpty:   return "Enter the initial battery percentage."
107
            case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
108
            }
109
        }
110
    }
111

            
Bogdan Timofte authored a month ago
112
@EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
113
    @EnvironmentObject private var usbMeter: Meter
114

            
115
    @State private var showingInlineTargetEditor = false
116
    @State private var draftTargetText = ""
117
    @State private var showingStopConfirm = false
Bogdan Timofte authored a month ago
118
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
119
    @State private var finalCheckpointText = ""
120
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
121
    @State private var draftChargingTransportMode: ChargingTransportMode?
122
    @State private var draftChargingStateMode: ChargingStateMode?
123
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
124
    @State private var initialCheckpoint = ""
125
    @State private var showsMeterTotalsInfo = false
126
    @State private var activeMode: ActiveMode = .chargeSession
Bogdan Timofte authored a month ago
127
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
128
    @State private var trimBannerDismissedForSessionID: UUID?
Bogdan Timofte authored a month ago
129
    @State private var sessionChartWidth: CGFloat = 760
Bogdan Timofte authored a month ago
130

            
131
    private var shouldShowTrimBanner: Bool {
132
        guard let session = openChargeSession,
133
              session.isTrimmed == false,
134
              trimBannerDismissedForSessionID != session.id else { return false }
135
        guard let window = detectedTrimWindow else { return false }
136
        return window.trimRatio > ChargingWindowDetector.significantTrimThreshold
137
    }
Bogdan Timofte authored a month ago
138

            
Bogdan Timofte authored a month ago
139
    var body: some View {
140
        ScrollView {
Bogdan Timofte authored a month ago
141
            VStack(spacing: 14) {
142
                statusHeader
Bogdan Timofte authored a month ago
143

            
Bogdan Timofte authored a month ago
144
                if let openChargeSession {
145
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
146

            
Bogdan Timofte authored a month ago
147
                    if shouldShowTrimBanner {
148
                        trimDetectionBanner(for: openChargeSession)
149
                    }
150

            
151
                    if shouldShowSessionChart(for: openChargeSession) {
152
                        sessionChartCard(
153
                            timeRange: sessionChartFixedTimeRange(for: openChargeSession),
154
                            session: openChargeSession
155
                        )
Bogdan Timofte authored a month ago
156
                    }
157
                } else {
Bogdan Timofte authored a month ago
158
                    liveMeterStripView
Bogdan Timofte authored a month ago
159
                    modePicker
160

            
161
                    switch activeMode {
162
                    case .chargeSession:
163
                        chargeSessionSetupCard
164
                    case .standbyPower:
165
                        standbyPowerCard
166
                    }
Bogdan Timofte authored a month ago
167
                }
168
            }
169
            .padding()
170
        }
171
        .background(
172
            LinearGradient(
173
                colors: [.pink.opacity(0.14), Color.clear],
174
                startPoint: .topLeading,
175
                endPoint: .bottomTrailing
176
            )
177
            .ignoresSafeArea()
178
        )
Bogdan Timofte authored a month ago
179
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
180
            Alert(
181
                title: Text("Delete Battery Checkpoint"),
182
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
183
                primaryButton: .destructive(Text("Delete")) {
184
                    if let openChargeSession {
185
                        _ = appData.deleteBatteryCheckpoint(
186
                            checkpointID: checkpoint.id,
187
                            for: openChargeSession.id
188
                        )
189
                    }
190
                },
191
                secondaryButton: .cancel()
192
            )
193
        }
Bogdan Timofte authored a month ago
194
        .onAppear {
Bogdan Timofte authored a month ago
195
            syncActiveSessionRestore()
Bogdan Timofte authored a month ago
196
            syncDraftSelections()
Bogdan Timofte authored a month ago
197
            runTrimDetection()
Bogdan Timofte authored a month ago
198
        }
199
        .onChange(of: selectedChargedDevice?.id) { _ in
200
            syncDraftSelections()
201
        }
202
        .onChange(of: openChargeSession?.id) { _ in
Bogdan Timofte authored a month ago
203
            syncActiveSessionRestore()
Bogdan Timofte authored a month ago
204
            syncDraftSelections()
Bogdan Timofte authored a month ago
205
            showingInlineTargetEditor = false
206
            draftTargetText = ""
Bogdan Timofte authored a month ago
207
            detectedTrimWindow = nil
208
            trimBannerDismissedForSessionID = nil
209
            runTrimDetection()
210
        }
211
        .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
212
            syncActiveSessionRestore()
213
            runTrimDetection()
214
        }
215
    }
216

            
217
    private func syncActiveSessionRestore() {
218
        guard let session = openChargeSession else { return }
219
        guard session.status == .active else { return }
220
        guard session.meterMACAddress == meterMACAddress else { return }
221
        usbMeter.restoreChargeRecordIfNeeded(from: session)
222
    }
223

            
224
    private func runTrimDetection() {
225
        guard let session = openChargeSession,
226
              session.isTrimmed == false,
227
              !session.aggregatedSamples.isEmpty else {
228
            detectedTrimWindow = nil
229
            return
Bogdan Timofte authored a month ago
230
        }
Bogdan Timofte authored a month ago
231
        let sessionEnd = session.endedAt ?? session.lastObservedAt
232
        detectedTrimWindow = ChargingWindowDetector.detect(
233
            samples: session.aggregatedSamples,
234
            sessionStart: session.startedAt,
235
            sessionEnd: sessionEnd
236
        )
Bogdan Timofte authored a month ago
237
    }
238

            
Bogdan Timofte authored a month ago
239
    // MARK: - Computed Properties
240

            
Bogdan Timofte authored a month ago
241
    private var meterMACAddress: String {
242
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
243
    }
244

            
Bogdan Timofte authored a month ago
245
    private var selectedChargedDevice: ChargedDeviceSummary? {
246
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
247
    }
248

            
Bogdan Timofte authored a month ago
249
    private var availableChargedDevices: [ChargedDeviceSummary] {
250
        appData.deviceSummaries
251
    }
252

            
253
    private var selectedChargedDeviceID: Binding<UUID?> {
254
        Binding(
255
            get: { selectedChargedDevice?.id },
256
            set: { newValue in
257
                guard let newValue else { return }
258
                _ = appData.assignChargedDevice(newValue, to: meterMACAddress)
259
            }
260
        )
261
    }
262

            
Bogdan Timofte authored a month ago
263
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
264
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
265
    }
266

            
Bogdan Timofte authored a month ago
267
    private var availableChargers: [ChargedDeviceSummary] {
268
        appData.chargerSummaries
269
    }
270

            
271
    private var selectedChargerID: Binding<UUID?> {
272
        Binding(
273
            get: { selectedCharger?.id },
274
            set: { newValue in
275
                guard let newValue else { return }
276
                _ = appData.assignCharger(newValue, to: meterMACAddress)
277
            }
278
        )
279
    }
280

            
Bogdan Timofte authored a month ago
281
    private var openChargeSession: ChargeSessionSummary? {
282
        appData.activeChargeSessionSummary(for: meterMACAddress)
283
    }
284

            
Bogdan Timofte authored a month ago
285
    private var showsMeterTotalsCard: Bool {
286
        usbMeter.supportsRecordingView
287
            || usbMeter.supportsDataGroupCommands
288
            || usbMeter.recordedAH > 0
289
            || usbMeter.recordedWH > 0
290
            || usbMeter.recordingDuration > 0
291
    }
292

            
Bogdan Timofte authored a month ago
293
    private var selectedDraftTransportMode: ChargingTransportMode? {
294
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
295
    }
296

            
297
    private var selectedDraftChargingStateMode: ChargingStateMode? {
298
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
299
    }
300

            
Bogdan Timofte authored a month ago
301
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
302
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
303
        let normalized = initialCheckpoint
304
            .trimmingCharacters(in: .whitespacesAndNewlines)
305
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
306
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
307
        return value
308
    }
309

            
Bogdan Timofte authored a month ago
310
    private var hasInitialCheckpointInput: Bool {
311
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
312
    }
313

            
314
    private var shouldRequireInitialCheckpoint: Bool {
315
        initialCheckpointMode == .known
316
    }
317

            
Bogdan Timofte authored a month ago
318
    private var requiresExplicitTransportSelection: Bool {
319
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
320
    }
321

            
322
    private var requiresExplicitChargingStateSelection: Bool {
323
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
324
    }
325

            
Bogdan Timofte authored a month ago
326
    private var startRequirements: [SessionStartRequirement] {
327
        var requirements: [SessionStartRequirement] = []
328

            
329
        if openChargeSession != nil {
330
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
331
        }
332

            
Bogdan Timofte authored a month ago
333
        guard let selectedChargedDevice else {
334
            requirements.append(.device)
335
            return requirements
Bogdan Timofte authored a month ago
336
        }
337

            
Bogdan Timofte authored a month ago
338
        guard let chargingTransportMode = selectedDraftTransportMode else {
339
            requirements.append(.chargingType)
340
            return requirements
Bogdan Timofte authored a month ago
341
        }
342

            
Bogdan Timofte authored a month ago
343
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
344
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
345
        }
346

            
Bogdan Timofte authored a month ago
347
        guard let chargingStateMode = selectedDraftChargingStateMode else {
348
            requirements.append(.chargingMode)
349
            return requirements
350
        }
351

            
352
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
353
            requirements.append(.chargingMode)
354
        }
355

            
356
        if chargingTransportMode == .wireless, selectedCharger == nil {
357
            requirements.append(.charger)
358
        }
359

            
360
        if shouldRequireInitialCheckpoint {
361
            if hasInitialCheckpointInput == false {
362
                requirements.append(.initialCheckpointEmpty)
363
            } else if initialCheckpointValue == nil {
364
                requirements.append(.initialCheckpointInvalid)
365
            }
366
        }
367

            
368
        return requirements
369
    }
370

            
371
    private var canStartSession: Bool {
372
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
373
    }
374

            
375
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
376
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
377
        return openChargeSession.status.title
378
    }
379

            
380
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
381
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
382
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
383
        case .active:    return .red
384
        case .paused:    return .orange
385
        case .completed: return .green
386
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
387
        }
388
    }
389

            
Bogdan Timofte authored a month ago
390
    private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
391
        sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session)
392
    }
393

            
394
    private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
395
        if usesChargeRecordBuffer(for: session) {
396
            return nil
397
        }
398
        return session.effectiveTimeRange
399
    }
400

            
401
    private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
402
        guard usesChargeRecordBuffer(for: session) else {
403
            return (nil, nil)
404
        }
405
        return (session.trimStart, session.trimEnd)
406
    }
407

            
408
    private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
409
        session.status.isOpen && session.meterMACAddress == meterMACAddress
Bogdan Timofte authored a month ago
410
    }
411

            
Bogdan Timofte authored a month ago
412
    private var showsWirelessChargerSection: Bool {
413
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
414
        return transportMode == .wireless
415
    }
Bogdan Timofte authored a month ago
416

            
Bogdan Timofte authored a month ago
417
    // MARK: - Status Header
418

            
419
    private var statusHeader: some View {
420
        HStack {
421
            Image(systemName: "bolt.fill")
422
                .foregroundColor(.pink)
423
            Text("Charging Session")
424
                .font(.system(.title3, design: .rounded).weight(.bold))
425
            Spacer()
426
            Text(headerStatusTitle)
427
                .font(.caption.weight(.bold))
428
                .foregroundColor(headerStatusColor)
429
                .padding(.horizontal, 10)
430
                .padding(.vertical, 6)
431
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
432
        }
433
        .padding(.horizontal, 18)
434
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
435
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
436
    }
437

            
Bogdan Timofte authored a month ago
438
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
439

            
Bogdan Timofte authored a month ago
440
    private var modePicker: some View {
441
        Picker("", selection: $activeMode) {
442
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
443
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
444
        }
Bogdan Timofte authored a month ago
445
        .pickerStyle(.segmented)
446
        .labelsHidden()
Bogdan Timofte authored a month ago
447
    }
448

            
Bogdan Timofte authored a month ago
449
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
450

            
Bogdan Timofte authored a month ago
451
    private var chargeSessionSetupCard: some View {
452
        VStack(alignment: .leading, spacing: 0) {
453
            // Device
454
            setupRow(icon: "iphone", iconColor: .blue) {
Bogdan Timofte authored a month ago
455
                Picker(selection: selectedChargedDeviceID) {
456
                    Text("Choose device").tag(UUID?.none)
457
                    ForEach(availableChargedDevices) { device in
458
                        Text(device.name).tag(Optional(device.id))
459
                    }
460
                } label: {
461
                    HStack(spacing: 8) {
462
                        if let device = selectedChargedDevice {
463
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
464
                                .font(.subheadline.weight(.semibold))
465
                        } else {
466
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
467
                                .foregroundColor(.secondary)
468
                                .font(.subheadline)
469
                        }
470
                        Spacer(minLength: 8)
471
                        Image(systemName: "chevron.up.chevron.down")
472
                            .font(.caption.weight(.semibold))
473
                            .foregroundColor(.secondary)
474
                    }
Bogdan Timofte authored a month ago
475
                }
Bogdan Timofte authored a month ago
476
                .pickerStyle(.menu)
477
                .disabled(availableChargedDevices.isEmpty)
Bogdan Timofte authored a month ago
478
            }
479

            
Bogdan Timofte authored a month ago
480
            // Charging type — only when device supports multiple
481
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
482
                Divider().padding(.leading, 46)
483
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
484
                    Text("Type")
485
                        .foregroundColor(.secondary)
486
                        .font(.subheadline)
487
                    Spacer()
Bogdan Timofte authored a month ago
488
                    compactSelectionMenu(
489
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
490
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
491
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
492
                                id: mode.id, title: mode.title,
493
                                isSelected: draftChargingTransportMode == mode,
494
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
495
                            )
Bogdan Timofte authored a month ago
496
                        }
Bogdan Timofte authored a month ago
497
                    )
Bogdan Timofte authored a month ago
498
                }
Bogdan Timofte authored a month ago
499
            }
Bogdan Timofte authored a month ago
500

            
Bogdan Timofte authored a month ago
501
            // Charging state — only when device supports multiple
502
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
503
                Divider().padding(.leading, 46)
504
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
505
                    Text("Mode")
506
                        .foregroundColor(.secondary)
507
                        .font(.subheadline)
508
                    Spacer()
Bogdan Timofte authored a month ago
509
                    compactSelectionMenu(
510
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
511
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
512
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
513
                                id: mode.id, title: mode.title,
514
                                isSelected: draftChargingStateMode == mode,
515
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
516
                            )
Bogdan Timofte authored a month ago
517
                        }
Bogdan Timofte authored a month ago
518
                    )
Bogdan Timofte authored a month ago
519
                }
520
            }
Bogdan Timofte authored a month ago
521

            
Bogdan Timofte authored a month ago
522
            // Wireless charger — only when wireless transport
523
            if showsWirelessChargerSection {
524
                Divider().padding(.leading, 46)
525
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
526
                    Picker(selection: selectedChargerID) {
527
                        Text("Choose charger").tag(UUID?.none)
528
                        ForEach(availableChargers) { charger in
529
                            Text(charger.name).tag(Optional(charger.id))
530
                        }
531
                    } label: {
532
                        HStack(spacing: 8) {
533
                            if let charger = selectedCharger {
534
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
535
                                    .font(.subheadline.weight(.semibold))
536
                                if charger.chargerIdleCurrentAmps == nil {
537
                                    Image(systemName: "exclamationmark.triangle.fill")
538
                                        .foregroundColor(.orange)
539
                                        .font(.caption)
540
                                }
541
                            } else {
542
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
543
                                    .foregroundColor(.secondary)
544
                                    .font(.subheadline)
545
                            }
546
                            Spacer(minLength: 8)
547
                            Image(systemName: "chevron.up.chevron.down")
548
                                .font(.caption.weight(.semibold))
549
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
550
                        }
Bogdan Timofte authored a month ago
551
                    }
Bogdan Timofte authored a month ago
552
                    .pickerStyle(.menu)
553
                    .disabled(availableChargers.isEmpty)
Bogdan Timofte authored a month ago
554
                }
Bogdan Timofte authored a month ago
555
            }
556

            
Bogdan Timofte authored a month ago
557
            // Battery checkpoint
558
            Divider().padding(.leading, 46)
559
            setupRow(icon: "battery.75percent", iconColor: .green) {
560
                if initialCheckpointMode == .known {
561
                    Button { adjustInitialCheckpoint(by: -1) } label: {
562
                        Image(systemName: "minus.circle").font(.title3)
563
                    }
564
                    .buttonStyle(.plain)
565

            
566
                    TextField("—", text: $initialCheckpoint)
567
                        .keyboardType(.decimalPad)
568
                        .textFieldStyle(.roundedBorder)
569
                        .frame(width: 52)
570
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
571

            
Bogdan Timofte authored a month ago
572
                    Text("%")
573
                        .font(.subheadline)
574
                        .foregroundColor(.secondary)
575

            
576
                    Button { adjustInitialCheckpoint(by: 1) } label: {
577
                        Image(systemName: "plus.circle").font(.title3)
578
                    }
579
                    .buttonStyle(.plain)
580
                } else {
581
                    Text(initialCheckpointMode == .flat
582
                         ? "Flat (device off / discharged)"
583
                         : "Unknown")
584
                        .font(.subheadline)
585
                        .foregroundColor(.secondary)
586
                }
587
                Spacer()
Bogdan Timofte authored a month ago
588
                compactSelectionMenu(
589
                    title: initialCheckpointMode.title,
590
                    options: InitialCheckpointMode.allCases.map { mode in
591
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
592
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
593
                            isSelected: initialCheckpointMode == mode,
594
                            action: { initialCheckpointMode = mode }
595
                        )
596
                    }
597
                )
Bogdan Timofte authored a month ago
598
            }
599

            
Bogdan Timofte authored a month ago
600
            // Requirement errors
601
            if startRequirements.isEmpty == false {
602
                Divider()
603
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
604
                    ForEach(startRequirements) { requirement in
605
                        Label(requirement.message, systemImage: "exclamationmark.circle")
606
                            .font(.caption)
607
                            .foregroundColor(.orange)
608
                    }
609
                }
Bogdan Timofte authored a month ago
610
                .padding(.horizontal, 14)
611
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
612
            }
Bogdan Timofte authored a month ago
613

            
Bogdan Timofte authored a month ago
614
            // Start button
615
            Divider()
Bogdan Timofte authored a month ago
616
            Button("Start Session") {
617
                startSession()
618
            }
619
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
620
            .padding(.vertical, 11)
621
            .font(.subheadline.weight(.semibold))
622
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
623
            .buttonStyle(.plain)
624
            .disabled(!canStartSession)
625
        }
Bogdan Timofte authored a month ago
626
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
627
    }
628

            
Bogdan Timofte authored a month ago
629
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
630

            
Bogdan Timofte authored a month ago
631
    private var standbyPowerCard: some View {
632
        VStack(alignment: .leading, spacing: 12) {
633
            HStack(spacing: 10) {
634
                Image(systemName: "powersleep")
635
                    .foregroundColor(.orange)
636
                    .font(.title3)
637
                VStack(alignment: .leading, spacing: 2) {
638
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
639
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
640
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
641
                        .font(.caption)
642
                        .foregroundColor(.secondary)
643
                }
Bogdan Timofte authored a month ago
644
            }
Bogdan Timofte authored a month ago
645

            
Bogdan Timofte authored a month ago
646
            NavigationLink(
647
                destination: ChargerStandbyPowerWizardView(
648
                    preferredMeterMACAddress: meterMACAddress
649
                )
650
            ) {
651
                HStack {
652
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
653
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
654
                    Text("New Measurement")
Bogdan Timofte authored a month ago
655
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
656
                    Spacer()
657
                    Image(systemName: "chevron.right")
658
                        .font(.caption.weight(.semibold))
659
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
660
                }
Bogdan Timofte authored a month ago
661
                .padding(.vertical, 10)
662
                .padding(.horizontal, 14)
663
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
664
            }
Bogdan Timofte authored a month ago
665
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
666
        }
Bogdan Timofte authored a month ago
667
        .padding(18)
668
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
669
    }
670

            
Bogdan Timofte authored a month ago
671
    // MARK: - Live Meter Strip (idle state)
672

            
673
    private var liveMeterStripView: some View {
674
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
675
        return LazyVGrid(columns: columns, spacing: 8) {
676
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
677
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
678
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
679
        }
680
    }
681

            
Bogdan Timofte authored a month ago
682
    // MARK: - Charging Monitor Card
683

            
Bogdan Timofte authored a month ago
684
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
685
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
Bogdan Timofte authored a month ago
686
        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
Bogdan Timofte authored a month ago
687
        let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
Bogdan Timofte authored a month ago
688
        let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
689
            for: openChargeSession,
690
            effectiveEnergyWhOverride: displayedEnergyWh
691
        )
Bogdan Timofte authored a month ago
692

            
Bogdan Timofte authored a month ago
693
        return VStack(alignment: .leading, spacing: 14) {
Bogdan Timofte authored a month ago
694
            // Header
695
            HStack {
696
                if let device = selectedChargedDevice {
697
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
698
                        .font(.headline)
699
                } else {
700
                    Text("Charging Monitor").font(.headline)
701
                }
702
                Spacer()
703
                Text(openChargeSession.status.title)
704
                    .font(.caption.weight(.bold))
705
                    .foregroundColor(headerStatusColor)
706
                    .padding(.horizontal, 8)
707
                    .padding(.vertical, 4)
708
                    .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
Bogdan Timofte authored a month ago
709
            }
Bogdan Timofte authored a month ago
710

            
Bogdan Timofte authored a month ago
711
            // Orphaned session warning — device was deleted from library
712
            if selectedChargedDevice == nil {
713
                VStack(alignment: .leading, spacing: 8) {
714
                    Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
715
                        .font(.subheadline.weight(.semibold))
716
                        .foregroundColor(.orange)
717
                    Text("The device associated with this session no longer exists. Stop the session to close it.")
718
                        .font(.caption)
719
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
720
                    Button("Terminate Session") {
Bogdan Timofte authored a month ago
721
                        _ = appData.stopChargeSession(
722
                            sessionID: openChargeSession.id,
723
                            finalBatteryPercent: nil
724
                        )
725
                    }
726
                    .frame(maxWidth: .infinity)
727
                    .padding(.vertical, 9)
728
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
729
                    .buttonStyle(.plain)
730
                }
731
                .padding(14)
732
                .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
733
            }
734

            
735
            // Battery prediction gauge
736
            if let batteryPrediction {
737
                batteryGaugeSection(
738
                    prediction: batteryPrediction,
739
                    session: openChargeSession,
740
                    displayedEnergyWh: displayedEnergyWh
741
                )
742
            }
743

            
744
            // Metrics grid
745
            sessionMetricsGrid(
746
                for: openChargeSession,
747
                displayedEnergyWh: displayedEnergyWh,
748
                hasPrediction: batteryPrediction != nil
Bogdan Timofte authored a month ago
749
            )
750

            
Bogdan Timofte authored a month ago
751
            if openChargeSession.stopThresholdAmps > 0 {
Bogdan Timofte authored a month ago
752
                Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
Bogdan Timofte authored a month ago
753
                    .font(.caption)
754
                    .foregroundColor(.secondary)
755
            }
756

            
Bogdan Timofte authored a month ago
757
            if let sessionWarning = sessionWarning(for: openChargeSession) {
Bogdan Timofte authored a month ago
758
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
Bogdan Timofte authored a month ago
759
                    .font(.caption)
760
                    .foregroundColor(.orange)
761
            }
762

            
763
            if openChargeSession.isPaused {
Bogdan Timofte authored a month ago
764
                Label(
765
                    "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
766
                    systemImage: "pause.circle"
767
                )
768
                .font(.caption)
769
                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
770
            }
771

            
Bogdan Timofte authored a month ago
772
            if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
Bogdan Timofte authored a month ago
773
                completionConfirmationCard(openChargeSession)
774
            }
775

            
Bogdan Timofte authored a month ago
776
            BatteryCheckpointSectionView(
777
                sessionID: openChargeSession.id,
778
                checkpoints: openChargeSession.checkpoints,
Bogdan Timofte authored a month ago
779
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
Bogdan Timofte authored a month ago
780
                canAddCheckpoint: canAddCheckpoint,
781
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
782
                effectiveEnergyWhOverride: displayedEnergyWh,
783
                measuredChargeAhOverride: displayedChargeAh,
784
                onDelete: { checkpoint in
785
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
786
                }
Bogdan Timofte authored a month ago
787
            )
Bogdan Timofte authored a month ago
788

            
Bogdan Timofte authored a month ago
789
            targetSectionView(
790
                for: openChargeSession,
Bogdan Timofte authored a month ago
791
                predictedPercent: batteryPrediction?.predictedPercent
Bogdan Timofte authored a month ago
792
            )
793

            
794
            if showingStopConfirm {
795
                stopConfirmPanel(for: openChargeSession)
796
            } else {
797
                HStack(spacing: 10) {
798
                    if openChargeSession.status == .active {
799
                        Button("Pause") {
800
                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
801
                        }
802
                        .frame(maxWidth: .infinity)
803
                        .padding(.vertical, 10)
804
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
805
                        .buttonStyle(.plain)
806
                    } else if openChargeSession.status == .paused {
807
                        Button("Resume") {
808
                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
809
                        }
810
                        .frame(maxWidth: .infinity)
811
                        .padding(.vertical, 10)
812
                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
813
                        .buttonStyle(.plain)
814
                    }
Bogdan Timofte authored a month ago
815

            
Bogdan Timofte authored a month ago
816
                    Button("Terminate Session") {
817
                        finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
818
                        finalCheckpointText = ""
819
                        showingStopConfirm = true
820
                    }
821
                    .frame(maxWidth: .infinity)
822
                    .padding(.vertical, 10)
823
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
824
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
825
                }
Bogdan Timofte authored a month ago
826
            }
827
        }
828
        .padding(18)
829
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
830
    }
831

            
Bogdan Timofte authored a month ago
832
    // MARK: - Battery Gauge Section
833

            
834
    private func batteryGaugeSection(
835
        prediction: BatteryLevelPrediction,
836
        session: ChargeSessionSummary,
837
        displayedEnergyWh: Double
838
    ) -> some View {
839
        let percent = prediction.predictedPercent
840
        let color = batteryColor(for: percent)
Bogdan Timofte authored a month ago
841
        let duration = displayedSessionDuration(for: session)
Bogdan Timofte authored a month ago
842
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
843
            ? displayedEnergyWh / duration
844
            : nil
845

            
846
        let etaToFull: String? = {
847
            guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
848
            let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
849
            let seconds = remaining / rate
850
            return seconds > 120 ? formatETA(seconds) : nil
851
        }()
852

            
853
        let etaToTarget: String? = {
854
            guard let target = session.targetBatteryPercent, target > percent + 1,
855
                  let rate = rateWhPerSec, rate > 0.0001 else { return nil }
856
            let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
857
            let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
858
            let seconds = remaining / rate
859
            return seconds > 120 ? formatETA(seconds) : nil
860
        }()
861

            
862
        return VStack(spacing: 10) {
863
            HStack(alignment: .lastTextBaseline, spacing: 8) {
864
                HStack(alignment: .lastTextBaseline, spacing: 3) {
865
                    Text("\(Int(percent.rounded()))")
866
                        .font(.system(size: 52, weight: .bold, design: .rounded))
867
                        .foregroundColor(color)
868
                        .monospacedDigit()
869
                    Text("%")
870
                        .font(.title2.weight(.semibold))
871
                        .foregroundColor(color.opacity(0.8))
872
                }
873
                Spacer()
874
                VStack(alignment: .trailing, spacing: 2) {
875
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
876
                        .font(.callout.weight(.bold))
877
                        .foregroundColor(.orange)
878
                        .monospacedDigit()
879
                    Text("est. capacity")
880
                        .font(.caption2)
881
                        .foregroundColor(.secondary)
882
                }
883
            }
884

            
885
            batteryProgressBar(
886
                percent: percent,
887
                startPercent: session.startBatteryPercent,
888
                targetPercent: session.targetBatteryPercent
889
            )
890

            
891
            HStack(spacing: 14) {
892
                if let etaToFull {
893
                    VStack(alignment: .leading, spacing: 1) {
894
                        HStack(spacing: 4) {
895
                            Image(systemName: "clock.fill")
896
                                .font(.caption)
897
                                .foregroundColor(.green)
898
                            Text(etaToFull)
899
                                .font(.caption.weight(.bold))
900
                        }
901
                        Text("to full")
902
                            .font(.caption2)
903
                            .foregroundColor(.secondary)
904
                    }
905
                }
906
                if let etaToTarget, let target = session.targetBatteryPercent {
907
                    VStack(alignment: .leading, spacing: 1) {
908
                        HStack(spacing: 4) {
909
                            Image(systemName: "bell.badge.fill")
910
                                .font(.caption)
911
                                .foregroundColor(.indigo)
912
                            Text(etaToTarget)
913
                                .font(.caption.weight(.bold))
914
                        }
915
                        Text("to \(Int(target.rounded()))%")
916
                            .font(.caption2)
917
                            .foregroundColor(.secondary)
918
                    }
919
                }
920
                Spacer()
921
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
922
                    .font(.caption2)
923
                    .foregroundColor(.secondary)
924
                    .multilineTextAlignment(.trailing)
925
            }
926
        }
927
        .padding(14)
928
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
929
    }
930

            
931
    private func batteryProgressBar(
932
        percent: Double,
933
        startPercent: Double?,
934
        targetPercent: Double?
935
    ) -> some View {
936
        let color = batteryColor(for: percent)
937
        return GeometryReader { geo in
938
            let width = geo.size.width
939
            ZStack(alignment: .leading) {
940
                Capsule()
941
                    .fill(Color.primary.opacity(0.10))
942
                Rectangle()
943
                    .fill(
944
                        LinearGradient(
945
                            colors: [color.opacity(0.6), color],
946
                            startPoint: .leading,
947
                            endPoint: .trailing
948
                        )
949
                    )
950
                    .frame(width: max(width * CGFloat(percent / 100), 4))
951
                    .animation(.easeInOut(duration: 0.4), value: percent)
952
                if let start = startPercent, start > 2, start < 98 {
953
                    Rectangle()
954
                        .fill(Color.white.opacity(0.55))
955
                        .frame(width: 2, height: 20)
956
                        .offset(x: width * CGFloat(start / 100) - 1)
957
                }
958
                if let target = targetPercent {
959
                    Rectangle()
960
                        .fill(Color.indigo.opacity(0.9))
961
                        .frame(width: 2.5, height: 20)
962
                        .offset(x: width * CGFloat(target / 100) - 1.25)
963
                }
964
            }
965
            .clipShape(Capsule())
966
        }
967
        .frame(height: 20)
968
    }
969

            
970
    private func batteryColor(for percent: Double) -> Color {
971
        if percent >= 75 { return .green }
972
        if percent >= 35 { return .orange }
973
        return .red
974
    }
975

            
976
    private func formatETA(_ seconds: TimeInterval) -> String {
977
        let totalMinutes = Int(seconds / 60)
978
        if totalMinutes < 60 { return "\(totalMinutes)m" }
979
        let hours = totalMinutes / 60
980
        let minutes = totalMinutes % 60
981
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
982
    }
983

            
984
    // MARK: - Session Metrics Grid
985

            
986
    private func sessionMetricsGrid(
987
        for session: ChargeSessionSummary,
988
        displayedEnergyWh: Double,
989
        hasPrediction: Bool
990
    ) -> some View {
Bogdan Timofte authored a month ago
991
        let displayedDuration = displayedSessionDuration(for: session)
Bogdan Timofte authored a month ago
992
        let capacityFallback: Double? = hasPrediction ? nil : (
993
            session.capacityEstimateWh
994
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
995
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh
996
        )
997
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
998

            
999
        return LazyVGrid(columns: columns, spacing: 8) {
1000
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
Bogdan Timofte authored a month ago
1001
            metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal)
Bogdan Timofte authored a month ago
1002

            
1003
            if shouldShowChargingTransport(for: session) {
1004
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
1005
            }
1006
            if shouldShowChargingState(for: session) {
1007
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
1008
            }
1009

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

            
1012
            if let capacity = capacityFallback {
1013
                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
1014
            }
1015
        }
1016
    }
1017

            
1018
    private func metricCell(label: String, value: String, tint: Color) -> some View {
1019
        VStack(alignment: .leading, spacing: 3) {
1020
            Text(label)
1021
                .font(.caption2)
1022
                .foregroundColor(.secondary)
1023
            Text(value)
1024
                .font(.subheadline.weight(.semibold))
1025
                .lineLimit(1)
1026
                .minimumScaleFactor(0.7)
1027
                .monospacedDigit()
1028
        }
1029
        .frame(maxWidth: .infinity, alignment: .leading)
1030
        .padding(.horizontal, 12)
1031
        .padding(.vertical, 10)
1032
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
1033
    }
1034

            
Bogdan Timofte authored a month ago
1035
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
1036
        VStack(alignment: .leading, spacing: 10) {
1037
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
1038
                .font(.subheadline.weight(.semibold))
1039

            
1040
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
1041
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
1042
                    .font(.caption)
1043
                    .foregroundColor(.secondary)
1044
            } else {
1045
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
1046
                    .font(.caption)
1047
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1048
            }
1049

            
Bogdan Timofte authored a month ago
1050
            HStack(spacing: 10) {
1051
                Button("Finish") {
Bogdan Timofte authored a month ago
1052
                    finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
1053
                    finalCheckpointText = ""
1054
                    showingStopConfirm = true
Bogdan Timofte authored a month ago
1055
                }
1056
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
1057
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1058
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1059
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1060

            
1061
                Button("Keep Monitoring") {
1062
                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
1063
                }
1064
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
1065
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1066
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1067
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1068
            }
1069
        }
Bogdan Timofte authored a month ago
1070
        .padding(14)
1071
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
1072
    }
1073

            
Bogdan Timofte authored a month ago
1074
    // MARK: - Target Section
1075

            
1076
    private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
1077
        let draftBelowPrediction: Bool = {
1078
            guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
1079
            return draft <= predicted
1080
        }()
1081
        let savedBelowPrediction: Bool = {
1082
            guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
1083
            return saved <= predicted
1084
        }()
1085

            
1086
        return HStack(alignment: .center, spacing: 8) {
1087
            Image(systemName: "bell.badge")
1088
                .foregroundColor(.indigo)
1089
                .font(.subheadline)
1090

            
1091
            Text("Notify at")
Bogdan Timofte authored a month ago
1092
                .font(.subheadline.weight(.semibold))
1093

            
Bogdan Timofte authored a month ago
1094
            Spacer(minLength: 8)
1095

            
1096
            if showingInlineTargetEditor {
1097
                Button {
1098
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1099
                    let next = max(current - 1, 1)
1100
                    draftTargetText = next.format(decimalDigits: 0)
1101
                } label: {
1102
                    Image(systemName: "minus.circle")
1103
                        .font(.title3)
1104
                }
1105
                .buttonStyle(.plain)
1106

            
1107
                TextField("—", text: $draftTargetText)
1108
                    .keyboardType(.decimalPad)
1109
                    .textFieldStyle(.roundedBorder)
1110
                    .frame(width: 48)
1111
                    .multilineTextAlignment(.center)
1112
                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
1113

            
1114
                Text("%")
1115
                    .font(.subheadline)
Bogdan Timofte authored a month ago
1116
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1117

            
1118
                if draftBelowPrediction {
1119
                    Button {} label: {
1120
                        Image(systemName: "exclamationmark.triangle.fill")
1121
                            .font(.body.weight(.semibold))
1122
                            .foregroundColor(.orange)
1123
                    }
1124
                    .buttonStyle(.plain)
1125
                    .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.")
1126
                }
1127

            
1128
                Button {
1129
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1130
                    let next = min(current + 1, 100)
1131
                    draftTargetText = next.format(decimalDigits: 0)
1132
                } label: {
1133
                    Image(systemName: "plus.circle")
1134
                        .font(.title3)
1135
                }
1136
                .buttonStyle(.plain)
1137

            
1138
                Button {
1139
                    if let value = parsedDraftTarget {
1140
                        _ = appData.setTargetBatteryPercent(value, for: session.id)
1141
                    }
1142
                    showingInlineTargetEditor = false
1143
                } label: {
1144
                    Image(systemName: "checkmark.circle.fill")
1145
                        .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1146
                        .font(.title3)
1147
                }
1148
                .buttonStyle(.plain)
1149
                .disabled(parsedDraftTarget == nil)
1150

            
1151
                Button {
1152
                    showingInlineTargetEditor = false
1153
                    draftTargetText = ""
1154
                } label: {
1155
                    Image(systemName: "xmark.circle")
1156
                        .foregroundColor(.secondary)
1157
                        .font(.title3)
1158
                }
1159
                .buttonStyle(.plain)
1160

            
Bogdan Timofte authored a month ago
1161
            } else {
Bogdan Timofte authored a month ago
1162
                if let targetPercent = session.targetBatteryPercent {
1163
                    Text("\(targetPercent.format(decimalDigits: 0))%")
1164
                        .font(.subheadline.weight(.semibold))
1165
                        .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1166

            
1167
                    if savedBelowPrediction {
1168
                        Button {} label: {
1169
                            Image(systemName: "exclamationmark.triangle.fill")
1170
                                .font(.callout.weight(.semibold))
1171
                                .foregroundColor(.orange)
1172
                        }
1173
                        .buttonStyle(.plain)
1174
                        .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.")
1175
                    }
1176

            
1177
                    Button {
1178
                        _ = appData.setTargetBatteryPercent(nil, for: session.id)
1179
                    } label: {
1180
                        Image(systemName: "xmark.circle.fill")
1181
                            .foregroundColor(.secondary)
1182
                            .font(.callout)
1183
                    }
1184
                    .buttonStyle(.plain)
1185
                    .help("Remove alert")
1186
                }
1187

            
1188
                Button {
1189
                    draftTargetText = session.targetBatteryPercent.map {
1190
                        $0.format(decimalDigits: 0)
1191
                    } ?? "80"
1192
                    showingInlineTargetEditor = true
1193
                } label: {
1194
                    Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1195
                        .font(.caption.weight(.semibold))
1196
                        .frame(width: 30, height: 30)
1197
                        .contentShape(Rectangle())
1198
                }
1199
                .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1200
                .buttonStyle(.plain)
1201
                .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
Bogdan Timofte authored a month ago
1202
            }
Bogdan Timofte authored a month ago
1203
        }
1204
    }
Bogdan Timofte authored a month ago
1205

            
Bogdan Timofte authored a month ago
1206
    private var parsedDraftTarget: Double? {
1207
        let normalized = draftTargetText
1208
            .trimmingCharacters(in: .whitespacesAndNewlines)
1209
            .replacingOccurrences(of: ",", with: ".")
1210
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1211
        return value
1212
    }
1213

            
1214
    private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
1215
        VStack(alignment: .leading, spacing: 12) {
1216
            Text("Final Checkpoint (optional)")
1217
                .font(.subheadline.weight(.semibold))
1218

            
1219
            HStack(spacing: 8) {
1220
                ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1221
                    Button {
1222
                        finalCheckpointMode = mode
Bogdan Timofte authored a month ago
1223
                        if mode != .custom { finalCheckpointText = "" }
Bogdan Timofte authored a month ago
1224
                    } label: {
1225
                        VStack(spacing: 5) {
1226
                            Image(systemName: mode.icon)
1227
                                .font(.title3)
1228
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1229
                            Text(mode.label)
1230
                                .font(.caption.weight(.semibold))
1231
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1232
                        }
1233
                        .frame(maxWidth: .infinity)
1234
                        .padding(.vertical, 10)
Bogdan Timofte authored a month ago
1235
                        .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
Bogdan Timofte authored a month ago
1236
                        .meterCard(
1237
                            tint: finalCheckpointMode == mode ? .primary : .secondary,
1238
                            fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1239
                            strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1240
                            cornerRadius: 12
1241
                        )
1242
                    }
1243
                    .buttonStyle(.plain)
1244
                }
Bogdan Timofte authored a month ago
1245
            }
1246

            
Bogdan Timofte authored a month ago
1247
            if finalCheckpointMode == .custom {
1248
                HStack(spacing: 8) {
1249
                    Button { adjustFinalCheckpoint(by: -1) } label: {
1250
                        Image(systemName: "minus.circle").font(.title3)
1251
                    }
1252
                    .buttonStyle(.plain)
1253

            
1254
                    TextField("—", text: $finalCheckpointText)
1255
                        .keyboardType(.decimalPad)
1256
                        .textFieldStyle(.roundedBorder)
1257
                        .frame(width: 56)
1258
                        .multilineTextAlignment(.center)
1259

            
Bogdan Timofte authored a month ago
1260
                    Text("%").foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1261

            
1262
                    Button { adjustFinalCheckpoint(by: 1) } label: {
1263
                        Image(systemName: "plus.circle").font(.title3)
1264
                    }
1265
                    .buttonStyle(.plain)
1266

            
1267
                    Spacer()
1268
                }
1269
            }
1270

            
Bogdan Timofte authored a month ago
1271
            HStack(spacing: 8) {
1272
                Button("Discard") {
1273
                    _ = appData.deleteChargeSession(sessionID: session.id)
Bogdan Timofte authored a month ago
1274
                    showingStopConfirm = false
1275
                    finalCheckpointText = ""
Bogdan Timofte authored a month ago
1276
                    finalCheckpointMode = .full
Bogdan Timofte authored a month ago
1277
                }
1278
                .frame(maxWidth: .infinity)
1279
                .padding(.vertical, 9)
1280
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1281
                .buttonStyle(.plain)
1282

            
Bogdan Timofte authored a month ago
1283
                let saveDisabled = finalCheckpointMode == .custom
Bogdan Timofte authored a month ago
1284
                    && finalCheckpointText.isEmpty == false
1285
                    && parsedFinalCheckpoint == nil
1286

            
Bogdan Timofte authored a month ago
1287
                Button("Save") {
Bogdan Timofte authored a month ago
1288
                    _ = appData.stopChargeSession(
1289
                        sessionID: session.id,
1290
                        finalBatteryPercent: resolvedFinalCheckpoint
1291
                    )
1292
                    showingStopConfirm = false
1293
                    finalCheckpointText = ""
1294
                    finalCheckpointMode = .full
1295
                }
1296
                .frame(maxWidth: .infinity)
1297
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1298
                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1299
                .buttonStyle(.plain)
1300
                .disabled(saveDisabled)
1301

            
1302
                Button("Cancel") {
1303
                    showingStopConfirm = false
1304
                    finalCheckpointText = ""
1305
                    finalCheckpointMode = .full
1306
                }
1307
                .frame(maxWidth: .infinity)
1308
                .padding(.vertical, 9)
1309
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
Bogdan Timofte authored a month ago
1310
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1311
            }
1312
        }
1313
        .padding(14)
Bogdan Timofte authored a month ago
1314
        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
Bogdan Timofte authored a month ago
1315
    }
1316

            
Bogdan Timofte authored a month ago
1317
    private var parsedFinalCheckpoint: Double? {
1318
        let normalized = finalCheckpointText
1319
            .trimmingCharacters(in: .whitespacesAndNewlines)
1320
            .replacingOccurrences(of: ",", with: ".")
1321
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1322
        return value
1323
    }
1324

            
1325
    private var resolvedFinalCheckpoint: Double? {
1326
        switch finalCheckpointMode {
1327
        case .full:   return 100.0
1328
        case .skip:   return nil
1329
        case .custom: return parsedFinalCheckpoint
1330
        }
1331
    }
1332

            
1333
    private func adjustFinalCheckpoint(by delta: Double) {
1334
        let current = parsedFinalCheckpoint ?? 0
1335
        let next = min(max(current + delta, 0), 100)
1336
        finalCheckpointText = next.format(decimalDigits: 0)
1337
    }
1338

            
Bogdan Timofte authored a month ago
1339
    // MARK: - Trim Detection Banner
1340

            
1341
    @ViewBuilder
1342
    private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
1343
        if let window = detectedTrimWindow {
1344
            HStack(spacing: 12) {
1345
                Image(systemName: "scissors.circle.fill")
1346
                    .font(.title3)
1347
                    .foregroundColor(.blue)
1348

            
1349
                VStack(alignment: .leading, spacing: 2) {
1350
                    Text("Charging ended early")
1351
                        .font(.subheadline.weight(.semibold))
1352
                    Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
1353
                        .font(.caption)
1354
                        .foregroundColor(.secondary)
1355
                        .fixedSize(horizontal: false, vertical: true)
1356
                }
1357

            
1358
                Spacer(minLength: 0)
1359

            
1360
                VStack(spacing: 6) {
1361
                    Button("Apply") {
1362
                        _ = appData.setSessionTrim(
1363
                            sessionID: session.id,
1364
                            start: window.start,
1365
                            end: window.end
1366
                        )
1367
                        trimBannerDismissedForSessionID = session.id
1368
                    }
1369
                    .font(.caption.weight(.semibold))
1370
                    .buttonStyle(.borderedProminent)
1371
                    .controlSize(.small)
1372
                    .tint(.blue)
1373

            
1374
                    Button {
1375
                        trimBannerDismissedForSessionID = session.id
1376
                    } label: {
1377
                        Image(systemName: "xmark")
1378
                            .font(.caption2.weight(.semibold))
1379
                            .foregroundColor(.secondary)
1380
                    }
1381
                    .buttonStyle(.plain)
1382
                }
1383
            }
1384
            .padding(14)
1385
            .background(
1386
                RoundedRectangle(cornerRadius: 14)
1387
                    .fill(Color.blue.opacity(0.10))
1388
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1389
            )
1390
            .transition(.opacity.combined(with: .move(edge: .top)))
1391
        }
1392
    }
1393

            
1394
    private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
1395
        let hasRangeSelector = session.aggregatedSamples.isEmpty == false
1396
        let chartWidth = max(sessionChartWidth, 1)
1397
        let compactChartLayout = MeasurementChartView.prefersCompactEmbeddedLayout(forWidth: chartWidth)
1398
        let plotReferenceHeight = MeasurementChartView.embeddedPlotReferenceHeight(compactLayout: compactChartLayout)
1399

            
1400
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1401
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1402
                Image(systemName: "chart.xyaxis.line")
1403
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1404
                Text("Session Chart")
1405
                    .font(.headline)
1406
                ContextInfoButton(
1407
                    title: "Session Chart",
Bogdan Timofte authored a month ago
1408
                    message: usesChargeRecordBuffer(for: session)
1409
                        ? "This chart combines the persisted session curve with current live data from this meter."
1410
                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
Bogdan Timofte authored a month ago
1411
                )
Bogdan Timofte authored a month ago
1412
                Spacer(minLength: 0)
Bogdan Timofte authored a month ago
1413
            }
Bogdan Timofte authored a month ago
1414

            
Bogdan Timofte authored a month ago
1415
            MeasurementChartView(
1416
                compactLayout: compactChartLayout,
1417
                availableSize: CGSize(width: chartWidth, height: plotReferenceHeight),
1418
                timeRange: timeRange,
1419
                timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1420
                timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1421
                showsRangeSelector: hasRangeSelector,
1422
                rebasesEnergyToVisibleRangeStart: true,
1423
                extendsTimelineToPresent: false,
1424
                rangeSelectorConfiguration: hasRangeSelector
1425
                    ? MeasurementChartRangeSelectorConfiguration(
1426
                        keepAction: MeasurementChartSelectionAction(
1427
                            title: compactChartLayout ? "Keep" : "Keep Selection",
1428
                            systemName: "scissors",
1429
                            tone: .destructive,
1430
                            handler: { range in
1431
                                _ = appData.setSessionTrim(
1432
                                    sessionID: session.id,
1433
                                    start: range.lowerBound,
1434
                                    end: range.upperBound
1435
                                )
1436
                                trimBannerDismissedForSessionID = session.id
1437
                            }
1438
                        ),
1439
                        removeAction: nil,
1440
                        resetAction: MeasurementChartResetAction(
1441
                            title: compactChartLayout ? "Reset" : "Reset Trim",
1442
                            systemName: "arrow.counterclockwise",
1443
                            tone: .reversible,
1444
                            confirmationTitle: "Reset session trim?",
1445
                            confirmationButtonTitle: "Reset trim",
1446
                            handler: {
1447
                                _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1448
                            }
Bogdan Timofte authored a month ago
1449
                        )
Bogdan Timofte authored a month ago
1450
                    )
1451
                    : nil
1452
            )
1453
            .environmentObject(usbMeter.chargeRecordMeasurements)
1454
            .frame(maxWidth: .infinity, alignment: .topLeading)
1455
            .frame(height: MeasurementChartView.embeddedContentHeight(width: chartWidth, showsRangeSelector: hasRangeSelector))
1456
            .background(
1457
                GeometryReader { geometry in
1458
                    Color.clear.preference(key: SessionChartWidthPreferenceKey.self, value: geometry.size.width)
1459
                }
1460
            )
1461
            .onPreferenceChange(SessionChartWidthPreferenceKey.self) { width in
1462
                guard width > 0, abs(width - sessionChartWidth) > 0.5 else { return }
1463
                sessionChartWidth = width
Bogdan Timofte authored a month ago
1464
            }
Bogdan Timofte authored a month ago
1465
        }
1466
        .padding(18)
1467
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1468
    }
1469

            
1470
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1471
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1472
            HStack(spacing: 8) {
1473
                Text("Meter Recorder")
1474
                    .font(.headline)
1475

            
1476
                Spacer(minLength: 0)
1477

            
1478
                Button {
1479
                    showsMeterTotalsInfo.toggle()
1480
                } label: {
1481
                    Image(systemName: "info.circle")
1482
                        .font(.body.weight(.semibold))
1483
                        .foregroundColor(.secondary)
1484
                }
1485
                .buttonStyle(.plain)
1486
                .accessibilityLabel("Meter recorder info")
1487
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
1488
                    VStack(alignment: .leading, spacing: 10) {
1489
                        Text("Meter Recorder")
1490
                            .font(.headline)
1491
                        Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
1492
                            .font(.body)
1493
                            .fixedSize(horizontal: false, vertical: true)
1494
                    }
1495
                    .padding(16)
1496
                    .frame(width: 280, alignment: .leading)
1497
                }
1498
            }
Bogdan Timofte authored a month ago
1499

            
1500
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
1501
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1502
                values: [
1503
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1504
                    usbMeter.recordingDurationDescription,
1505
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1506
                ]
1507
            )
Bogdan Timofte authored a month ago
1508

            
1509
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1510
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1511
                    .font(.caption)
1512
                    .foregroundColor(.secondary)
1513
            }
Bogdan Timofte authored a month ago
1514
        }
1515
        .padding(18)
1516
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1517
    }
1518

            
Bogdan Timofte authored a month ago
1519
    // MARK: - Helpers
1520

            
1521
    private func setupRow<Content: View>(
1522
        icon: String,
1523
        iconColor: Color = .secondary,
1524
        @ViewBuilder content: () -> Content
1525
    ) -> some View {
1526
        HStack(spacing: 10) {
1527
            Image(systemName: icon)
1528
                .foregroundColor(iconColor)
1529
                .font(.body.weight(.medium))
1530
                .frame(width: 22, alignment: .center)
1531
            content()
1532
        }
1533
        .padding(.horizontal, 14)
1534
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1535
    }
1536

            
1537
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1538
        if session.autoStopEnabled == false {
1539
            return "Manual"
1540
        }
1541
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1542
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1543
        }
1544
        if session.stopThresholdAmps > 0 {
1545
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1546
        }
1547
        return "Learning"
1548
    }
1549

            
Bogdan Timofte authored a month ago
1550
    private func sessionMetricRows(
1551
        for session: ChargeSessionSummary,
1552
        displayedEnergyWh: Double
1553
    ) -> [SessionMetricRow] {
1554
        var rows: [SessionMetricRow] = []
1555

            
1556
        if shouldShowChargingTransport(for: session) {
1557
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1558
        }
1559

            
1560
        if shouldShowChargingState(for: session) {
1561
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1562
        }
1563

            
1564
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
Bogdan Timofte authored a month ago
1565
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
Bogdan Timofte authored a month ago
1566
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1567
        return rows
1568
    }
1569

            
1570
    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1571
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1572
        return selectedChargedDevice.supportedChargingModes.count > 1
1573
            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1574
    }
1575

            
1576
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1577
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1578
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1579
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1580
    }
1581

            
Bogdan Timofte authored a month ago
1582
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1583
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1584
        guard session.isTrimmed == false else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1585
        guard session.status.isOpen else { return storedEnergyWh }
1586
        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1587
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1588
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1589
        }
1590
        return storedEnergyWh
1591
    }
1592

            
Bogdan Timofte authored a month ago
1593
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1594
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1595
        guard session.isTrimmed == false else { return storedChargeAh }
Bogdan Timofte authored a month ago
1596
        guard session.status.isOpen else { return storedChargeAh }
1597
        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
Bogdan Timofte authored a month ago
1598
        if let baselineChargeAh = session.meterChargeBaselineAh {
1599
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1600
        }
1601
        return storedChargeAh
1602
    }
1603

            
Bogdan Timofte authored a month ago
1604
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1605
        let storedDuration = max(session.effectiveDuration, 0)
1606
        guard session.isTrimmed == false else { return storedDuration }
1607
        guard session.status.isOpen else { return storedDuration }
1608
        guard session.meterMACAddress == meterMACAddress else { return storedDuration }
1609
        return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
1610
    }
1611

            
Bogdan Timofte authored a month ago
1612
    private func formatDuration(_ duration: TimeInterval) -> String {
1613
        let totalSeconds = Int(duration.rounded(.down))
1614
        let hours = totalSeconds / 3600
1615
        let minutes = (totalSeconds % 3600) / 60
1616
        let seconds = totalSeconds % 60
1617
        if hours > 0 {
1618
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1619
        }
1620
        return String(format: "%02d:%02d", minutes, seconds)
1621
    }
1622

            
Bogdan Timofte authored a month ago
1623
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1624
        guard session.chargingTransportMode == .wireless,
1625
              let chargerID = session.chargerID,
1626
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1627
            return nil
1628
        }
Bogdan Timofte authored a month ago
1629
        guard charger.chargerIdleCurrentAmps == nil else { return nil }
Bogdan Timofte authored a month ago
1630
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1631
    }
1632

            
1633
    private func startSession() {
1634
        guard let selectedChargedDevice,
1635
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1636
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1637
            return
1638
        }
1639

            
1640
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1641
        let didStart = appData.startChargeSession(
1642
            for: usbMeter,
1643
            chargedDeviceID: selectedChargedDevice.id,
1644
            chargerID: chargerID,
1645
            chargingTransportMode: chargingTransportMode,
1646
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1647
            autoStopEnabled: false,
1648
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1649
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1650
        )
Bogdan Timofte authored a month ago
1651

            
1652
        if didStart {
1653
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1654
            initialCheckpointMode = .known
1655
        }
1656
    }
1657

            
1658
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1659
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1660
        let currentValue = initialCheckpointValue ?? 0
1661
        let nextValue = min(max(currentValue + delta, 0), 100)
1662
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1663
    }
1664

            
Bogdan Timofte authored a month ago
1665
    private func syncDraftSelections() {
1666
        guard let selectedChargedDevice else {
1667
            draftChargingTransportMode = nil
1668
            draftChargingStateMode = nil
1669
            return
1670
        }
1671

            
1672
        if let openChargeSession {
1673
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1674
            draftChargingStateMode = openChargeSession.chargingStateMode
1675
            return
1676
        }
1677

            
1678
        if let draftChargingTransportMode,
1679
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1680
            self.draftChargingTransportMode = nil
1681
        }
1682

            
1683
        if let draftChargingStateMode,
1684
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1685
            self.draftChargingStateMode = nil
1686
        }
1687

            
1688
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1689
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1690
        }
1691

            
Bogdan Timofte authored a month ago
1692
        if let draftChargingTransportMode {
1693
            draftChargingStateMode = draftChargingStateMode
1694
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1695
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1696
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1697
        }
Bogdan Timofte authored a month ago
1698
    }
Bogdan Timofte authored a month ago
1699

            
1700
    private struct CompactSelectionOption: Identifiable {
1701
        let id: String
1702
        let title: String
1703
        let isSelected: Bool
1704
        let action: () -> Void
1705
    }
1706

            
1707
    private func compactSelectionMenu(
1708
        title: String,
1709
        options: [CompactSelectionOption]
1710
    ) -> some View {
1711
        Menu {
1712
            ForEach(options) { option in
1713
                Button {
1714
                    option.action()
1715
                } label: {
1716
                    if option.isSelected {
1717
                        Label(option.title, systemImage: "checkmark")
1718
                    } else {
1719
                        Text(option.title)
1720
                    }
1721
                }
1722
            }
1723
        } label: {
1724
            HStack(spacing: 8) {
1725
                Text(title)
1726
                    .foregroundColor(.primary)
1727
                Spacer()
1728
                Image(systemName: "chevron.up.chevron.down")
1729
                    .font(.caption.weight(.semibold))
1730
                    .foregroundColor(.secondary)
1731
            }
1732
            .padding(.horizontal, 12)
1733
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1734
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1735
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1736
        }
1737
        .buttonStyle(.plain)
1738
    }
Bogdan Timofte authored a month ago
1739
}