USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1757 lines | 72.394kb
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

            
18
struct MeterChargeRecordContentView: View {
Bogdan Timofte authored a month ago
19
    private struct SessionMetricRow {
20
        let label: String
21
        let value: String
22
    }
23

            
Bogdan Timofte authored a month ago
24
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
25
        case known
26
        case unknown
27
        case flat
28

            
29
        var id: String { rawValue }
30

            
31
        var title: String {
32
            switch self {
Bogdan Timofte authored a month ago
33
            case .known:   return "Known"
34
            case .unknown: return "Unknown"
35
            case .flat:    return "Flat"
Bogdan Timofte authored a month ago
36
            }
37
        }
38
    }
39

            
Bogdan Timofte authored a month ago
40
    private enum ActiveMode: Hashable {
41
        case chargeSession
42
        case standbyPower
43
    }
Bogdan Timofte authored a month ago
44

            
Bogdan Timofte authored a month ago
45
    private enum FinalCheckpoint: Hashable {
46
        case full
47
        case skip
48
        case custom
49

            
50
        var label: String {
51
            switch self {
52
            case .full:   return "Full"
53
            case .skip:   return "Skip"
54
            case .custom: return "Other %"
55
            }
56
        }
57

            
58
        var icon: String {
59
            switch self {
60
            case .full:   return "battery.100percent"
61
            case .skip:   return "minus.circle"
62
            case .custom: return "pencil"
63
            }
64
        }
65
    }
66

            
Bogdan Timofte authored a month ago
67
    private enum SessionStartRequirement: Identifiable {
68
        case existingSession
69
        case device
70
        case chargingType
71
        case chargingMode
72
        case charger
73
        case initialCheckpointEmpty
74
        case initialCheckpointInvalid
75

            
76
        var id: String {
77
            switch self {
Bogdan Timofte authored a month ago
78
            case .existingSession:         return "existing-session"
79
            case .device:                  return "device"
80
            case .chargingType:            return "charging-type"
81
            case .chargingMode:            return "charging-mode"
82
            case .charger:                 return "charger"
83
            case .initialCheckpointEmpty:  return "initial-checkpoint-empty"
84
            case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
Bogdan Timofte authored a month ago
85
            }
86
        }
87

            
88
        var message: String {
89
            switch self {
Bogdan Timofte authored a month ago
90
            case .existingSession:          return "Stop or pause the current session before starting another one."
91
            case .device:                   return "Select the device that is charging."
92
            case .chargingType:             return "Choose the charging type for this session."
93
            case .chargingMode:             return "Choose whether the device is on or off for this session."
94
            case .charger:                  return "Select the wireless charger used in this session."
95
            case .initialCheckpointEmpty:   return "Enter the initial battery percentage."
96
            case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
97
            }
98
        }
99
    }
100

            
Bogdan Timofte authored a month ago
101
@EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
102
    @EnvironmentObject private var usbMeter: Meter
103

            
104
    @State private var showingInlineTargetEditor = false
105
    @State private var draftTargetText = ""
106
    @State private var showingStopConfirm = false
Bogdan Timofte authored a month ago
107
    @State private var finalCheckpointMode: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
108
    @State private var finalCheckpointText = ""
109
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
110
    @State private var draftChargingTransportMode: ChargingTransportMode?
111
    @State private var draftChargingStateMode: ChargingStateMode?
112
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
113
    @State private var initialCheckpoint = ""
114
    @State private var showsMeterTotalsInfo = false
115
    @State private var activeMode: ActiveMode = .chargeSession
Bogdan Timofte authored a month ago
116
    @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
117
    @State private var trimBannerDismissedForSessionID: UUID?
118

            
119
    private var shouldShowTrimBanner: Bool {
120
        guard let session = openChargeSession,
121
              session.isTrimmed == false,
122
              trimBannerDismissedForSessionID != session.id else { return false }
123
        guard let window = detectedTrimWindow else { return false }
124
        return window.trimRatio > ChargingWindowDetector.significantTrimThreshold
125
    }
Bogdan Timofte authored a month ago
126

            
Bogdan Timofte authored a month ago
127
    var body: some View {
128
        ScrollView {
Bogdan Timofte authored a month ago
129
            VStack(spacing: 14) {
130
                statusHeader
Bogdan Timofte authored a month ago
131

            
Bogdan Timofte authored a month ago
132
                if let openChargeSession {
133
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
134

            
Bogdan Timofte authored a month ago
135
                    if shouldShowTrimBanner {
136
                        trimDetectionBanner(for: openChargeSession)
137
                    }
138

            
139
                    if shouldShowSessionChart(for: openChargeSession) {
140
                        sessionChartCard(
141
                            timeRange: sessionChartFixedTimeRange(for: openChargeSession),
142
                            session: openChargeSession
143
                        )
Bogdan Timofte authored a month ago
144
                    }
145
                } else {
Bogdan Timofte authored a month ago
146
                    liveMeterStripView
Bogdan Timofte authored a month ago
147
                    modePicker
148

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

            
205
    private func syncActiveSessionRestore() {
206
        guard let session = openChargeSession else { return }
Bogdan Timofte authored a month ago
207
        guard session.status.isOpen else { return }
Bogdan Timofte authored a month ago
208
        guard session.meterMACAddress == meterMACAddress else { return }
Bogdan Timofte authored a month ago
209
        usbMeter.restoreChargeMonitoringIfNeeded(from: session)
Bogdan Timofte authored a month ago
210
    }
211

            
212
    private func runTrimDetection() {
213
        guard let session = openChargeSession,
214
              session.isTrimmed == false,
215
              !session.aggregatedSamples.isEmpty else {
216
            detectedTrimWindow = nil
217
            return
Bogdan Timofte authored a month ago
218
        }
Bogdan Timofte authored a month ago
219
        let sessionEnd = session.endedAt ?? session.lastObservedAt
220
        detectedTrimWindow = ChargingWindowDetector.detect(
221
            samples: session.aggregatedSamples,
222
            sessionStart: session.startedAt,
223
            sessionEnd: sessionEnd
224
        )
Bogdan Timofte authored a month ago
225
    }
226

            
Bogdan Timofte authored a month ago
227
    // MARK: - Computed Properties
228

            
Bogdan Timofte authored a month ago
229
    private var meterMACAddress: String {
230
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
231
    }
232

            
Bogdan Timofte authored a month ago
233
    private var selectedChargedDevice: ChargedDeviceSummary? {
234
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
235
    }
236

            
Bogdan Timofte authored a month ago
237
    private var availableChargedDevices: [ChargedDeviceSummary] {
238
        appData.deviceSummaries
239
    }
240

            
241
    private var selectedChargedDeviceID: Binding<UUID?> {
242
        Binding(
243
            get: { selectedChargedDevice?.id },
244
            set: { newValue in
245
                guard let newValue else { return }
246
                _ = appData.assignChargedDevice(newValue, to: meterMACAddress)
247
            }
248
        )
249
    }
250

            
Bogdan Timofte authored a month ago
251
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
252
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
253
    }
254

            
Bogdan Timofte authored a month ago
255
    private var availableChargers: [ChargedDeviceSummary] {
256
        appData.chargerSummaries
257
    }
258

            
259
    private var selectedChargerID: Binding<UUID?> {
260
        Binding(
261
            get: { selectedCharger?.id },
262
            set: { newValue in
263
                guard let newValue else { return }
264
                _ = appData.assignCharger(newValue, to: meterMACAddress)
265
            }
266
        )
267
    }
268

            
Bogdan Timofte authored a month ago
269
    private var openChargeSession: ChargeSessionSummary? {
270
        appData.activeChargeSessionSummary(for: meterMACAddress)
271
    }
272

            
Bogdan Timofte authored a month ago
273
    private var showsMeterTotalsCard: Bool {
274
        usbMeter.supportsRecordingView
275
            || usbMeter.supportsDataGroupCommands
276
            || usbMeter.recordedAH > 0
277
            || usbMeter.recordedWH > 0
278
            || usbMeter.recordingDuration > 0
279
    }
280

            
Bogdan Timofte authored a month ago
281
    private var selectedDraftTransportMode: ChargingTransportMode? {
282
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
283
    }
284

            
285
    private var selectedDraftChargingStateMode: ChargingStateMode? {
286
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
287
    }
288

            
Bogdan Timofte authored a month ago
289
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
290
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
291
        let normalized = initialCheckpoint
292
            .trimmingCharacters(in: .whitespacesAndNewlines)
293
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
294
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
295
        return value
296
    }
297

            
Bogdan Timofte authored a month ago
298
    private var hasInitialCheckpointInput: Bool {
299
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
300
    }
301

            
302
    private var shouldRequireInitialCheckpoint: Bool {
303
        initialCheckpointMode == .known
304
    }
305

            
Bogdan Timofte authored a month ago
306
    private var requiresExplicitTransportSelection: Bool {
307
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
308
    }
309

            
310
    private var requiresExplicitChargingStateSelection: Bool {
311
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
312
    }
313

            
Bogdan Timofte authored a month ago
314
    private var startRequirements: [SessionStartRequirement] {
315
        var requirements: [SessionStartRequirement] = []
316

            
317
        if openChargeSession != nil {
318
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
319
        }
320

            
Bogdan Timofte authored a month ago
321
        guard let selectedChargedDevice else {
322
            requirements.append(.device)
323
            return requirements
Bogdan Timofte authored a month ago
324
        }
325

            
Bogdan Timofte authored a month ago
326
        guard let chargingTransportMode = selectedDraftTransportMode else {
327
            requirements.append(.chargingType)
328
            return requirements
Bogdan Timofte authored a month ago
329
        }
330

            
Bogdan Timofte authored a month ago
331
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
332
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
333
        }
334

            
Bogdan Timofte authored a month ago
335
        guard let chargingStateMode = selectedDraftChargingStateMode else {
336
            requirements.append(.chargingMode)
337
            return requirements
338
        }
339

            
340
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
341
            requirements.append(.chargingMode)
342
        }
343

            
344
        if chargingTransportMode == .wireless, selectedCharger == nil {
345
            requirements.append(.charger)
346
        }
347

            
348
        if shouldRequireInitialCheckpoint {
349
            if hasInitialCheckpointInput == false {
350
                requirements.append(.initialCheckpointEmpty)
351
            } else if initialCheckpointValue == nil {
352
                requirements.append(.initialCheckpointInvalid)
353
            }
354
        }
355

            
356
        return requirements
357
    }
358

            
359
    private var canStartSession: Bool {
360
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
361
    }
362

            
363
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
364
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
365
        return openChargeSession.status.title
366
    }
367

            
368
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
369
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
370
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
371
        case .active:    return .red
372
        case .paused:    return .orange
373
        case .completed: return .green
374
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
375
        }
376
    }
377

            
Bogdan Timofte authored a month ago
378
    private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
379
        sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session)
380
    }
381

            
382
    private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
383
        if usesChargeRecordBuffer(for: session) {
384
            return nil
385
        }
386
        return session.effectiveTimeRange
387
    }
388

            
389
    private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
390
        guard usesChargeRecordBuffer(for: session) else {
391
            return (nil, nil)
392
        }
393
        return (session.trimStart, session.trimEnd)
394
    }
395

            
396
    private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
397
        session.status.isOpen && session.meterMACAddress == meterMACAddress
Bogdan Timofte authored a month ago
398
    }
399

            
Bogdan Timofte authored a month ago
400
    private var showsWirelessChargerSection: Bool {
401
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
402
        return transportMode == .wireless
403
    }
Bogdan Timofte authored a month ago
404

            
Bogdan Timofte authored a month ago
405
    // MARK: - Status Header
406

            
407
    private var statusHeader: some View {
408
        HStack {
409
            Image(systemName: "bolt.fill")
410
                .foregroundColor(.pink)
411
            Text("Charging Session")
412
                .font(.system(.title3, design: .rounded).weight(.bold))
413
            Spacer()
414
            Text(headerStatusTitle)
415
                .font(.caption.weight(.bold))
416
                .foregroundColor(headerStatusColor)
417
                .padding(.horizontal, 10)
418
                .padding(.vertical, 6)
419
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
420
        }
421
        .padding(.horizontal, 18)
422
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
423
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
424
    }
425

            
Bogdan Timofte authored a month ago
426
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
427

            
Bogdan Timofte authored a month ago
428
    private var modePicker: some View {
429
        Picker("", selection: $activeMode) {
430
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
431
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
432
        }
Bogdan Timofte authored a month ago
433
        .pickerStyle(.segmented)
434
        .labelsHidden()
Bogdan Timofte authored a month ago
435
    }
436

            
Bogdan Timofte authored a month ago
437
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
438

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

            
Bogdan Timofte authored a month ago
468
            // Charging type — only when device supports multiple
469
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
470
                Divider().padding(.leading, 46)
471
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
472
                    Text("Type")
473
                        .foregroundColor(.secondary)
474
                        .font(.subheadline)
475
                    Spacer()
Bogdan Timofte authored a month ago
476
                    compactSelectionMenu(
477
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
478
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
479
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
480
                                id: mode.id, title: mode.title,
481
                                isSelected: draftChargingTransportMode == mode,
482
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
483
                            )
Bogdan Timofte authored a month ago
484
                        }
Bogdan Timofte authored a month ago
485
                    )
Bogdan Timofte authored a month ago
486
                }
Bogdan Timofte authored a month ago
487
            }
Bogdan Timofte authored a month ago
488

            
Bogdan Timofte authored a month ago
489
            // Charging state — only when device supports multiple
490
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
491
                Divider().padding(.leading, 46)
492
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
493
                    Text("Mode")
494
                        .foregroundColor(.secondary)
495
                        .font(.subheadline)
496
                    Spacer()
Bogdan Timofte authored a month ago
497
                    compactSelectionMenu(
498
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
499
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
500
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
501
                                id: mode.id, title: mode.title,
502
                                isSelected: draftChargingStateMode == mode,
503
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
504
                            )
Bogdan Timofte authored a month ago
505
                        }
Bogdan Timofte authored a month ago
506
                    )
Bogdan Timofte authored a month ago
507
                }
508
            }
Bogdan Timofte authored a month ago
509

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

            
Bogdan Timofte authored a month ago
545
            // Battery checkpoint
546
            Divider().padding(.leading, 46)
547
            setupRow(icon: "battery.75percent", iconColor: .green) {
548
                if initialCheckpointMode == .known {
549
                    Button { adjustInitialCheckpoint(by: -1) } label: {
550
                        Image(systemName: "minus.circle").font(.title3)
551
                    }
552
                    .buttonStyle(.plain)
553

            
554
                    TextField("—", text: $initialCheckpoint)
555
                        .keyboardType(.decimalPad)
556
                        .textFieldStyle(.roundedBorder)
557
                        .frame(width: 52)
558
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
559

            
Bogdan Timofte authored a month ago
560
                    Text("%")
561
                        .font(.subheadline)
562
                        .foregroundColor(.secondary)
563

            
564
                    Button { adjustInitialCheckpoint(by: 1) } label: {
565
                        Image(systemName: "plus.circle").font(.title3)
566
                    }
567
                    .buttonStyle(.plain)
568
                } else {
569
                    Text(initialCheckpointMode == .flat
570
                         ? "Flat (device off / discharged)"
571
                         : "Unknown")
572
                        .font(.subheadline)
573
                        .foregroundColor(.secondary)
574
                }
575
                Spacer()
Bogdan Timofte authored a month ago
576
                compactSelectionMenu(
577
                    title: initialCheckpointMode.title,
578
                    options: InitialCheckpointMode.allCases.map { mode in
579
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
580
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
581
                            isSelected: initialCheckpointMode == mode,
582
                            action: { initialCheckpointMode = mode }
583
                        )
584
                    }
585
                )
Bogdan Timofte authored a month ago
586
            }
587

            
Bogdan Timofte authored a month ago
588
            // Requirement errors
589
            if startRequirements.isEmpty == false {
590
                Divider()
591
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
592
                    ForEach(startRequirements) { requirement in
593
                        Label(requirement.message, systemImage: "exclamationmark.circle")
594
                            .font(.caption)
595
                            .foregroundColor(.orange)
596
                    }
597
                }
Bogdan Timofte authored a month ago
598
                .padding(.horizontal, 14)
599
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
600
            }
Bogdan Timofte authored a month ago
601

            
Bogdan Timofte authored a month ago
602
            // Start button
603
            Divider()
Bogdan Timofte authored a month ago
604
            Button("Start Session") {
605
                startSession()
606
            }
607
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
608
            .padding(.vertical, 11)
609
            .font(.subheadline.weight(.semibold))
610
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
611
            .buttonStyle(.plain)
612
            .disabled(!canStartSession)
613
        }
Bogdan Timofte authored a month ago
614
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
615
    }
616

            
Bogdan Timofte authored a month ago
617
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
618

            
Bogdan Timofte authored a month ago
619
    private var standbyPowerCard: some View {
620
        VStack(alignment: .leading, spacing: 12) {
621
            HStack(spacing: 10) {
622
                Image(systemName: "powersleep")
623
                    .foregroundColor(.orange)
624
                    .font(.title3)
625
                VStack(alignment: .leading, spacing: 2) {
626
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
627
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
628
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
629
                        .font(.caption)
630
                        .foregroundColor(.secondary)
631
                }
Bogdan Timofte authored a month ago
632
            }
Bogdan Timofte authored a month ago
633

            
Bogdan Timofte authored a month ago
634
            NavigationLink(
635
                destination: ChargerStandbyPowerWizardView(
636
                    preferredMeterMACAddress: meterMACAddress
637
                )
638
            ) {
639
                HStack {
640
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
641
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
642
                    Text("New Measurement")
Bogdan Timofte authored a month ago
643
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
644
                    Spacer()
645
                    Image(systemName: "chevron.right")
646
                        .font(.caption.weight(.semibold))
647
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
648
                }
Bogdan Timofte authored a month ago
649
                .padding(.vertical, 10)
650
                .padding(.horizontal, 14)
651
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
652
            }
Bogdan Timofte authored a month ago
653
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
654
        }
Bogdan Timofte authored a month ago
655
        .padding(18)
656
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
657
    }
658

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

            
661
    private var liveMeterStripView: some View {
662
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
663
        return LazyVGrid(columns: columns, spacing: 8) {
664
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
665
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
666
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
667
        }
668
    }
669

            
Bogdan Timofte authored a month ago
670
    // MARK: - Charging Monitor Card
671

            
Bogdan Timofte authored a month ago
672
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
673
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
Bogdan Timofte authored a month ago
674
        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
Bogdan Timofte authored a month ago
675
        let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
Bogdan Timofte authored a month ago
676
        let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
677
            for: openChargeSession,
678
            effectiveEnergyWhOverride: displayedEnergyWh
679
        )
Bogdan Timofte authored a month ago
680

            
Bogdan Timofte authored a month ago
681
        return VStack(alignment: .leading, spacing: 14) {
Bogdan Timofte authored a month ago
682
            // Header
683
            HStack {
684
                if let device = selectedChargedDevice {
685
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
686
                        .font(.headline)
687
                } else {
688
                    Text("Charging Monitor").font(.headline)
689
                }
690
                Spacer()
691
                Text(openChargeSession.status.title)
692
                    .font(.caption.weight(.bold))
693
                    .foregroundColor(headerStatusColor)
694
                    .padding(.horizontal, 8)
695
                    .padding(.vertical, 4)
696
                    .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
Bogdan Timofte authored a month ago
697
            }
Bogdan Timofte authored a month ago
698

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

            
723
            // Battery prediction gauge
724
            if let batteryPrediction {
725
                batteryGaugeSection(
726
                    prediction: batteryPrediction,
727
                    session: openChargeSession,
728
                    displayedEnergyWh: displayedEnergyWh
729
                )
730
            }
731

            
732
            // Metrics grid
733
            sessionMetricsGrid(
734
                for: openChargeSession,
735
                displayedEnergyWh: displayedEnergyWh,
736
                hasPrediction: batteryPrediction != nil
Bogdan Timofte authored a month ago
737
            )
738

            
Bogdan Timofte authored a month ago
739
            if openChargeSession.stopThresholdAmps > 0 {
Bogdan Timofte authored a month ago
740
                Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
Bogdan Timofte authored a month ago
741
                    .font(.caption)
742
                    .foregroundColor(.secondary)
743
            }
744

            
Bogdan Timofte authored a month ago
745
            if let sessionWarning = sessionWarning(for: openChargeSession) {
Bogdan Timofte authored a month ago
746
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
Bogdan Timofte authored a month ago
747
                    .font(.caption)
748
                    .foregroundColor(.orange)
749
            }
750

            
751
            if openChargeSession.isPaused {
Bogdan Timofte authored a month ago
752
                Label(
753
                    "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
754
                    systemImage: "pause.circle"
755
                )
756
                .font(.caption)
757
                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
758
            }
759

            
Bogdan Timofte authored a month ago
760
            if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
Bogdan Timofte authored a month ago
761
                completionConfirmationCard(openChargeSession)
762
            }
763

            
Bogdan Timofte authored a month ago
764
            BatteryCheckpointSectionView(
765
                sessionID: openChargeSession.id,
766
                checkpoints: openChargeSession.checkpoints,
Bogdan Timofte authored a month ago
767
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
Bogdan Timofte authored a month ago
768
                canAddCheckpoint: canAddCheckpoint,
769
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
770
                effectiveEnergyWhOverride: displayedEnergyWh,
771
                measuredChargeAhOverride: displayedChargeAh,
772
                onDelete: { checkpoint in
773
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
774
                }
Bogdan Timofte authored a month ago
775
            )
Bogdan Timofte authored a month ago
776

            
Bogdan Timofte authored a month ago
777
            targetSectionView(
778
                for: openChargeSession,
Bogdan Timofte authored a month ago
779
                predictedPercent: batteryPrediction?.predictedPercent
Bogdan Timofte authored a month ago
780
            )
781

            
782
            if showingStopConfirm {
Bogdan Timofte authored a month ago
783
                stopConfirmPanel(
784
                    for: openChargeSession,
785
                    displayedEnergyWh: displayedEnergyWh,
786
                    displayedChargeAh: displayedChargeAh
787
                )
Bogdan Timofte authored a month ago
788
            } else {
789
                HStack(spacing: 10) {
790
                    if openChargeSession.status == .active {
791
                        Button("Pause") {
792
                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
793
                        }
794
                        .frame(maxWidth: .infinity)
795
                        .padding(.vertical, 10)
796
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
797
                        .buttonStyle(.plain)
798
                    } else if openChargeSession.status == .paused {
799
                        Button("Resume") {
800
                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
801
                        }
802
                        .frame(maxWidth: .infinity)
803
                        .padding(.vertical, 10)
804
                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
805
                        .buttonStyle(.plain)
806
                    }
Bogdan Timofte authored a month ago
807

            
Bogdan Timofte authored a month ago
808
                    Button("Terminate Session") {
809
                        finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
810
                        finalCheckpointText = ""
811
                        showingStopConfirm = true
812
                    }
813
                    .frame(maxWidth: .infinity)
814
                    .padding(.vertical, 10)
815
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
816
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
817
                }
Bogdan Timofte authored a month ago
818
            }
819
        }
820
        .padding(18)
821
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
822
    }
823

            
Bogdan Timofte authored a month ago
824
    // MARK: - Battery Gauge Section
825

            
826
    private func batteryGaugeSection(
827
        prediction: BatteryLevelPrediction,
828
        session: ChargeSessionSummary,
829
        displayedEnergyWh: Double
830
    ) -> some View {
831
        let percent = prediction.predictedPercent
832
        let color = batteryColor(for: percent)
Bogdan Timofte authored a month ago
833
        let duration = displayedSessionDuration(for: session)
Bogdan Timofte authored a month ago
834
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
835
            ? displayedEnergyWh / duration
836
            : nil
837

            
838
        let etaToFull: String? = {
839
            guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
840
            let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
841
            let seconds = remaining / rate
842
            return seconds > 120 ? formatETA(seconds) : nil
843
        }()
844

            
845
        let etaToTarget: String? = {
846
            guard let target = session.targetBatteryPercent, target > percent + 1,
847
                  let rate = rateWhPerSec, rate > 0.0001 else { return nil }
848
            let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
849
            let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
850
            let seconds = remaining / rate
851
            return seconds > 120 ? formatETA(seconds) : nil
852
        }()
853

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

            
877
            batteryProgressBar(
878
                percent: percent,
879
                startPercent: session.startBatteryPercent,
880
                targetPercent: session.targetBatteryPercent
881
            )
882

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

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

            
962
    private func batteryColor(for percent: Double) -> Color {
963
        if percent >= 75 { return .green }
964
        if percent >= 35 { return .orange }
965
        return .red
966
    }
967

            
968
    private func formatETA(_ seconds: TimeInterval) -> String {
969
        let totalMinutes = Int(seconds / 60)
970
        if totalMinutes < 60 { return "\(totalMinutes)m" }
971
        let hours = totalMinutes / 60
972
        let minutes = totalMinutes % 60
973
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
974
    }
975

            
976
    // MARK: - Session Metrics Grid
977

            
978
    private func sessionMetricsGrid(
979
        for session: ChargeSessionSummary,
980
        displayedEnergyWh: Double,
981
        hasPrediction: Bool
982
    ) -> some View {
Bogdan Timofte authored a month ago
983
        let displayedDuration = displayedSessionDuration(for: session)
Bogdan Timofte authored a month ago
984
        let capacityFallback: Double? = hasPrediction ? nil : (
985
            session.capacityEstimateWh
986
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
987
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh
988
        )
989
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
990

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

            
995
            if shouldShowChargingTransport(for: session) {
996
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
997
            }
998
            if shouldShowChargingState(for: session) {
999
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
1000
            }
1001

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

            
1004
            if let capacity = capacityFallback {
1005
                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
1006
            }
1007
        }
1008
    }
1009

            
1010
    private func metricCell(label: String, value: String, tint: Color) -> some View {
1011
        VStack(alignment: .leading, spacing: 3) {
1012
            Text(label)
1013
                .font(.caption2)
1014
                .foregroundColor(.secondary)
1015
            Text(value)
1016
                .font(.subheadline.weight(.semibold))
1017
                .lineLimit(1)
1018
                .minimumScaleFactor(0.7)
1019
                .monospacedDigit()
1020
        }
1021
        .frame(maxWidth: .infinity, alignment: .leading)
1022
        .padding(.horizontal, 12)
1023
        .padding(.vertical, 10)
1024
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
1025
    }
1026

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

            
1032
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
1033
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
1034
                    .font(.caption)
1035
                    .foregroundColor(.secondary)
1036
            } else {
1037
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
1038
                    .font(.caption)
1039
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1040
            }
1041

            
Bogdan Timofte authored a month ago
1042
            HStack(spacing: 10) {
1043
                Button("Finish") {
Bogdan Timofte authored a month ago
1044
                    finalCheckpointMode = .skip
Bogdan Timofte authored a month ago
1045
                    finalCheckpointText = ""
1046
                    showingStopConfirm = true
Bogdan Timofte authored a month ago
1047
                }
1048
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
1049
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1050
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1051
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1052

            
1053
                Button("Keep Monitoring") {
1054
                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
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: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1059
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1060
            }
1061
        }
Bogdan Timofte authored a month ago
1062
        .padding(14)
1063
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
1064
    }
1065

            
Bogdan Timofte authored a month ago
1066
    // MARK: - Target Section
1067

            
1068
    private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
1069
        let draftBelowPrediction: Bool = {
1070
            guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
1071
            return draft <= predicted
1072
        }()
1073
        let savedBelowPrediction: Bool = {
1074
            guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
1075
            return saved <= predicted
1076
        }()
1077

            
1078
        return HStack(alignment: .center, spacing: 8) {
1079
            Image(systemName: "bell.badge")
1080
                .foregroundColor(.indigo)
1081
                .font(.subheadline)
1082

            
1083
            Text("Notify at")
Bogdan Timofte authored a month ago
1084
                .font(.subheadline.weight(.semibold))
1085

            
Bogdan Timofte authored a month ago
1086
            Spacer(minLength: 8)
1087

            
1088
            if showingInlineTargetEditor {
1089
                Button {
1090
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1091
                    let next = max(current - 1, 1)
1092
                    draftTargetText = next.format(decimalDigits: 0)
1093
                } label: {
1094
                    Image(systemName: "minus.circle")
1095
                        .font(.title3)
1096
                }
1097
                .buttonStyle(.plain)
1098

            
1099
                TextField("—", text: $draftTargetText)
1100
                    .keyboardType(.decimalPad)
1101
                    .textFieldStyle(.roundedBorder)
1102
                    .frame(width: 48)
1103
                    .multilineTextAlignment(.center)
1104
                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
1105

            
1106
                Text("%")
1107
                    .font(.subheadline)
Bogdan Timofte authored a month ago
1108
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1109

            
1110
                if draftBelowPrediction {
1111
                    Button {} label: {
1112
                        Image(systemName: "exclamationmark.triangle.fill")
1113
                            .font(.body.weight(.semibold))
1114
                            .foregroundColor(.orange)
1115
                    }
1116
                    .buttonStyle(.plain)
1117
                    .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.")
1118
                }
1119

            
1120
                Button {
1121
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1122
                    let next = min(current + 1, 100)
1123
                    draftTargetText = next.format(decimalDigits: 0)
1124
                } label: {
1125
                    Image(systemName: "plus.circle")
1126
                        .font(.title3)
1127
                }
1128
                .buttonStyle(.plain)
1129

            
1130
                Button {
1131
                    if let value = parsedDraftTarget {
1132
                        _ = appData.setTargetBatteryPercent(value, for: session.id)
1133
                    }
1134
                    showingInlineTargetEditor = false
1135
                } label: {
1136
                    Image(systemName: "checkmark.circle.fill")
1137
                        .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1138
                        .font(.title3)
1139
                }
1140
                .buttonStyle(.plain)
1141
                .disabled(parsedDraftTarget == nil)
1142

            
1143
                Button {
1144
                    showingInlineTargetEditor = false
1145
                    draftTargetText = ""
1146
                } label: {
1147
                    Image(systemName: "xmark.circle")
1148
                        .foregroundColor(.secondary)
1149
                        .font(.title3)
1150
                }
1151
                .buttonStyle(.plain)
1152

            
Bogdan Timofte authored a month ago
1153
            } else {
Bogdan Timofte authored a month ago
1154
                if let targetPercent = session.targetBatteryPercent {
1155
                    Text("\(targetPercent.format(decimalDigits: 0))%")
1156
                        .font(.subheadline.weight(.semibold))
1157
                        .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1158

            
1159
                    if savedBelowPrediction {
1160
                        Button {} label: {
1161
                            Image(systemName: "exclamationmark.triangle.fill")
1162
                                .font(.callout.weight(.semibold))
1163
                                .foregroundColor(.orange)
1164
                        }
1165
                        .buttonStyle(.plain)
1166
                        .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.")
1167
                    }
1168

            
1169
                    Button {
1170
                        _ = appData.setTargetBatteryPercent(nil, for: session.id)
1171
                    } label: {
1172
                        Image(systemName: "xmark.circle.fill")
1173
                            .foregroundColor(.secondary)
1174
                            .font(.callout)
1175
                    }
1176
                    .buttonStyle(.plain)
1177
                    .help("Remove alert")
1178
                }
1179

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

            
Bogdan Timofte authored a month ago
1198
    private var parsedDraftTarget: Double? {
1199
        let normalized = draftTargetText
1200
            .trimmingCharacters(in: .whitespacesAndNewlines)
1201
            .replacingOccurrences(of: ",", with: ".")
1202
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1203
        return value
1204
    }
1205

            
Bogdan Timofte authored a month ago
1206
    private func stopConfirmPanel(
1207
        for session: ChargeSessionSummary,
1208
        displayedEnergyWh: Double,
1209
        displayedChargeAh: Double
1210
    ) -> some View {
1211
        let canSave = hasSavableChargeData(
1212
            for: session,
1213
            displayedEnergyWh: displayedEnergyWh,
1214
            displayedChargeAh: displayedChargeAh
1215
        )
1216
        let hasInvalidCustomCheckpoint = finalCheckpointMode == .custom
1217
            && finalCheckpointText.isEmpty == false
1218
            && parsedFinalCheckpoint == nil
1219

            
1220
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1221
            Text("Final Checkpoint (optional)")
1222
                .font(.subheadline.weight(.semibold))
1223

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

            
Bogdan Timofte authored a month ago
1252
            if finalCheckpointMode == .custom {
1253
                HStack(spacing: 8) {
1254
                    Button { adjustFinalCheckpoint(by: -1) } label: {
1255
                        Image(systemName: "minus.circle").font(.title3)
1256
                    }
1257
                    .buttonStyle(.plain)
1258

            
1259
                    TextField("—", text: $finalCheckpointText)
1260
                        .keyboardType(.decimalPad)
1261
                        .textFieldStyle(.roundedBorder)
1262
                        .frame(width: 56)
1263
                        .multilineTextAlignment(.center)
1264

            
Bogdan Timofte authored a month ago
1265
                    Text("%").foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1266

            
1267
                    Button { adjustFinalCheckpoint(by: 1) } label: {
1268
                        Image(systemName: "plus.circle").font(.title3)
1269
                    }
1270
                    .buttonStyle(.plain)
1271

            
1272
                    Spacer()
1273
                }
1274
            }
1275

            
Bogdan Timofte authored a month ago
1276
            if !canSave {
1277
                Label("This session has no charging data to save. Discard it instead.", systemImage: "exclamationmark.triangle.fill")
1278
                    .font(.caption)
1279
                    .foregroundColor(.red)
1280
                    .fixedSize(horizontal: false, vertical: true)
1281
            } else if hasInvalidCustomCheckpoint {
1282
                Label("Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint.", systemImage: "exclamationmark.triangle.fill")
1283
                    .font(.caption)
1284
                    .foregroundColor(.orange)
1285
                    .fixedSize(horizontal: false, vertical: true)
1286
            }
1287

            
Bogdan Timofte authored a month ago
1288
            HStack(spacing: 8) {
1289
                Button("Discard") {
1290
                    _ = appData.deleteChargeSession(sessionID: session.id)
Bogdan Timofte authored a month ago
1291
                    showingStopConfirm = false
1292
                    finalCheckpointText = ""
Bogdan Timofte authored a month ago
1293
                    finalCheckpointMode = .full
Bogdan Timofte authored a month ago
1294
                }
1295
                .frame(maxWidth: .infinity)
1296
                .padding(.vertical, 9)
1297
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1298
                .buttonStyle(.plain)
1299

            
Bogdan Timofte authored a month ago
1300
                Button("Save") {
Bogdan Timofte authored a month ago
1301
                    _ = appData.stopChargeSession(
1302
                        sessionID: session.id,
Bogdan Timofte authored a month ago
1303
                        finalBatteryPercent: resolvedFinalCheckpoint,
1304
                        from: usbMeter
Bogdan Timofte authored a month ago
1305
                    )
1306
                    showingStopConfirm = false
1307
                    finalCheckpointText = ""
1308
                    finalCheckpointMode = .full
1309
                }
1310
                .frame(maxWidth: .infinity)
1311
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1312
                .meterCard(
1313
                    tint: canSave ? .green : .red,
1314
                    fillOpacity: canSave ? 0.16 : 0.08,
1315
                    strokeOpacity: canSave ? 0.22 : 0.28,
1316
                    cornerRadius: 14
1317
                )
Bogdan Timofte authored a month ago
1318
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1319
                .disabled(!canSave)
1320
                .opacity(canSave ? 1 : 0.52)
Bogdan Timofte authored a month ago
1321

            
1322
                Button("Cancel") {
1323
                    showingStopConfirm = false
1324
                    finalCheckpointText = ""
1325
                    finalCheckpointMode = .full
1326
                }
1327
                .frame(maxWidth: .infinity)
1328
                .padding(.vertical, 9)
1329
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
Bogdan Timofte authored a month ago
1330
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1331
            }
1332
        }
1333
        .padding(14)
Bogdan Timofte authored a month ago
1334
        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
Bogdan Timofte authored a month ago
1335
    }
1336

            
Bogdan Timofte authored a month ago
1337
    private var parsedFinalCheckpoint: Double? {
1338
        let normalized = finalCheckpointText
1339
            .trimmingCharacters(in: .whitespacesAndNewlines)
1340
            .replacingOccurrences(of: ",", with: ".")
1341
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1342
        return value
1343
    }
1344

            
1345
    private var resolvedFinalCheckpoint: Double? {
1346
        switch finalCheckpointMode {
1347
        case .full:   return 100.0
1348
        case .skip:   return nil
1349
        case .custom: return parsedFinalCheckpoint
1350
        }
1351
    }
1352

            
1353
    private func adjustFinalCheckpoint(by delta: Double) {
1354
        let current = parsedFinalCheckpoint ?? 0
1355
        let next = min(max(current + delta, 0), 100)
1356
        finalCheckpointText = next.format(decimalDigits: 0)
1357
    }
1358

            
Bogdan Timofte authored a month ago
1359
    private func hasSavableChargeData(
1360
        for session: ChargeSessionSummary,
1361
        displayedEnergyWh: Double,
1362
        displayedChargeAh: Double
1363
    ) -> Bool {
1364
        session.hasSavableChargeData
1365
            || displayedEnergyWh > 0
1366
            || displayedChargeAh > 0
1367
    }
1368

            
Bogdan Timofte authored a month ago
1369
    // MARK: - Trim Detection Banner
1370

            
1371
    @ViewBuilder
1372
    private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
1373
        if let window = detectedTrimWindow {
1374
            HStack(spacing: 12) {
1375
                Image(systemName: "scissors.circle.fill")
1376
                    .font(.title3)
1377
                    .foregroundColor(.blue)
1378

            
1379
                VStack(alignment: .leading, spacing: 2) {
1380
                    Text("Charging ended early")
1381
                        .font(.subheadline.weight(.semibold))
1382
                    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.")
1383
                        .font(.caption)
1384
                        .foregroundColor(.secondary)
1385
                        .fixedSize(horizontal: false, vertical: true)
1386
                }
1387

            
1388
                Spacer(minLength: 0)
1389

            
1390
                VStack(spacing: 6) {
1391
                    Button("Apply") {
1392
                        _ = appData.setSessionTrim(
1393
                            sessionID: session.id,
1394
                            start: window.start,
1395
                            end: window.end
1396
                        )
1397
                        trimBannerDismissedForSessionID = session.id
1398
                    }
1399
                    .font(.caption.weight(.semibold))
1400
                    .buttonStyle(.borderedProminent)
1401
                    .controlSize(.small)
1402
                    .tint(.blue)
1403

            
1404
                    Button {
1405
                        trimBannerDismissedForSessionID = session.id
1406
                    } label: {
1407
                        Image(systemName: "xmark")
1408
                            .font(.caption2.weight(.semibold))
1409
                            .foregroundColor(.secondary)
1410
                    }
1411
                    .buttonStyle(.plain)
1412
                }
1413
            }
1414
            .padding(14)
1415
            .background(
1416
                RoundedRectangle(cornerRadius: 14)
1417
                    .fill(Color.blue.opacity(0.10))
1418
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1419
            )
1420
            .transition(.opacity.combined(with: .move(edge: .top)))
1421
        }
1422
    }
1423

            
1424
    private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
1425
        let hasRangeSelector = session.aggregatedSamples.isEmpty == false
1426

            
1427
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1428
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1429
                Image(systemName: "chart.xyaxis.line")
1430
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1431
                Text("Session Chart")
1432
                    .font(.headline)
1433
                ContextInfoButton(
1434
                    title: "Session Chart",
Bogdan Timofte authored a month ago
1435
                    message: usesChargeRecordBuffer(for: session)
1436
                        ? "This chart combines the persisted session curve with current live data from this meter."
1437
                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
Bogdan Timofte authored a month ago
1438
                )
Bogdan Timofte authored a month ago
1439
                Spacer(minLength: 0)
Bogdan Timofte authored a month ago
1440
            }
Bogdan Timofte authored a month ago
1441

            
Bogdan Timofte authored a month ago
1442
            MeasurementChartView(
1443
                timeRange: timeRange,
1444
                timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1445
                timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1446
                showsRangeSelector: hasRangeSelector,
1447
                rebasesEnergyToVisibleRangeStart: true,
1448
                extendsTimelineToPresent: false,
Bogdan Timofte authored a month ago
1449
                showsTemperatureSeries: false,
Bogdan Timofte authored a month ago
1450
                rangeSelectorConfiguration: hasRangeSelector
1451
                    ? MeasurementChartRangeSelectorConfiguration(
1452
                        keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1453
                            title: "Keep Selection",
1454
                            shortTitle: "Keep",
Bogdan Timofte authored a month ago
1455
                            systemName: "scissors",
1456
                            tone: .destructive,
1457
                            handler: { range in
1458
                                _ = appData.setSessionTrim(
1459
                                    sessionID: session.id,
1460
                                    start: range.lowerBound,
1461
                                    end: range.upperBound
1462
                                )
1463
                                trimBannerDismissedForSessionID = session.id
1464
                            }
1465
                        ),
1466
                        removeAction: nil,
1467
                        resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1468
                            title: "Reset Trim",
1469
                            shortTitle: "Reset",
Bogdan Timofte authored a month ago
1470
                            systemName: "arrow.counterclockwise",
1471
                            tone: .reversible,
1472
                            confirmationTitle: "Reset session trim?",
1473
                            confirmationButtonTitle: "Reset trim",
1474
                            handler: {
1475
                                _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1476
                            }
Bogdan Timofte authored a month ago
1477
                        )
Bogdan Timofte authored a month ago
1478
                    )
1479
                    : nil
1480
            )
1481
            .environmentObject(usbMeter.chargeRecordMeasurements)
1482
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
1483
        }
1484
        .padding(18)
1485
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1486
    }
1487

            
1488
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1489
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1490
            HStack(spacing: 8) {
1491
                Text("Meter Recorder")
1492
                    .font(.headline)
1493

            
1494
                Spacer(minLength: 0)
1495

            
1496
                Button {
1497
                    showsMeterTotalsInfo.toggle()
1498
                } label: {
1499
                    Image(systemName: "info.circle")
1500
                        .font(.body.weight(.semibold))
1501
                        .foregroundColor(.secondary)
1502
                }
1503
                .buttonStyle(.plain)
1504
                .accessibilityLabel("Meter recorder info")
1505
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
1506
                    VStack(alignment: .leading, spacing: 10) {
1507
                        Text("Meter Recorder")
1508
                            .font(.headline)
1509
                        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.")
1510
                            .font(.body)
1511
                            .fixedSize(horizontal: false, vertical: true)
1512
                    }
1513
                    .padding(16)
1514
                    .frame(width: 280, alignment: .leading)
1515
                }
1516
            }
Bogdan Timofte authored a month ago
1517

            
1518
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
1519
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1520
                values: [
1521
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1522
                    usbMeter.recordingDurationDescription,
1523
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1524
                ]
1525
            )
Bogdan Timofte authored a month ago
1526

            
1527
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1528
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1529
                    .font(.caption)
1530
                    .foregroundColor(.secondary)
1531
            }
Bogdan Timofte authored a month ago
1532
        }
1533
        .padding(18)
1534
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1535
    }
1536

            
Bogdan Timofte authored a month ago
1537
    // MARK: - Helpers
1538

            
1539
    private func setupRow<Content: View>(
1540
        icon: String,
1541
        iconColor: Color = .secondary,
1542
        @ViewBuilder content: () -> Content
1543
    ) -> some View {
1544
        HStack(spacing: 10) {
1545
            Image(systemName: icon)
1546
                .foregroundColor(iconColor)
1547
                .font(.body.weight(.medium))
1548
                .frame(width: 22, alignment: .center)
1549
            content()
1550
        }
1551
        .padding(.horizontal, 14)
1552
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1553
    }
1554

            
1555
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1556
        if session.autoStopEnabled == false {
1557
            return "Manual"
1558
        }
1559
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1560
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1561
        }
1562
        if session.stopThresholdAmps > 0 {
1563
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1564
        }
1565
        return "Learning"
1566
    }
1567

            
Bogdan Timofte authored a month ago
1568
    private func sessionMetricRows(
1569
        for session: ChargeSessionSummary,
1570
        displayedEnergyWh: Double
1571
    ) -> [SessionMetricRow] {
1572
        var rows: [SessionMetricRow] = []
1573

            
1574
        if shouldShowChargingTransport(for: session) {
1575
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1576
        }
1577

            
1578
        if shouldShowChargingState(for: session) {
1579
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1580
        }
1581

            
1582
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
Bogdan Timofte authored a month ago
1583
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
Bogdan Timofte authored a month ago
1584
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1585
        return rows
1586
    }
1587

            
1588
    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1589
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1590
        return selectedChargedDevice.supportedChargingModes.count > 1
1591
            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1592
    }
1593

            
1594
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1595
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1596
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1597
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1598
    }
1599

            
Bogdan Timofte authored a month ago
1600
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1601
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1602
        guard session.isTrimmed == false else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1603
        guard session.status.isOpen else { return storedEnergyWh }
1604
        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1605
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1606
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1607
        }
1608
        return storedEnergyWh
1609
    }
1610

            
Bogdan Timofte authored a month ago
1611
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1612
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1613
        guard session.isTrimmed == false else { return storedChargeAh }
Bogdan Timofte authored a month ago
1614
        guard session.status.isOpen else { return storedChargeAh }
1615
        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
Bogdan Timofte authored a month ago
1616
        if let baselineChargeAh = session.meterChargeBaselineAh {
1617
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1618
        }
1619
        return storedChargeAh
1620
    }
1621

            
Bogdan Timofte authored a month ago
1622
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1623
        let storedDuration = max(session.effectiveDuration, 0)
1624
        guard session.isTrimmed == false else { return storedDuration }
1625
        guard session.status.isOpen else { return storedDuration }
1626
        guard session.meterMACAddress == meterMACAddress else { return storedDuration }
1627
        return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
1628
    }
1629

            
Bogdan Timofte authored a month ago
1630
    private func formatDuration(_ duration: TimeInterval) -> String {
1631
        let totalSeconds = Int(duration.rounded(.down))
1632
        let hours = totalSeconds / 3600
1633
        let minutes = (totalSeconds % 3600) / 60
1634
        let seconds = totalSeconds % 60
1635
        if hours > 0 {
1636
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1637
        }
1638
        return String(format: "%02d:%02d", minutes, seconds)
1639
    }
1640

            
Bogdan Timofte authored a month ago
1641
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1642
        guard session.chargingTransportMode == .wireless,
1643
              let chargerID = session.chargerID,
1644
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1645
            return nil
1646
        }
Bogdan Timofte authored a month ago
1647
        guard charger.chargerIdleCurrentAmps == nil else { return nil }
Bogdan Timofte authored a month ago
1648
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1649
    }
1650

            
1651
    private func startSession() {
1652
        guard let selectedChargedDevice,
1653
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1654
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1655
            return
1656
        }
1657

            
1658
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1659
        let didStart = appData.startChargeSession(
1660
            for: usbMeter,
1661
            chargedDeviceID: selectedChargedDevice.id,
1662
            chargerID: chargerID,
1663
            chargingTransportMode: chargingTransportMode,
1664
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1665
            autoStopEnabled: false,
1666
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1667
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1668
        )
Bogdan Timofte authored a month ago
1669

            
1670
        if didStart {
1671
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1672
            initialCheckpointMode = .known
1673
        }
1674
    }
1675

            
1676
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1677
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1678
        let currentValue = initialCheckpointValue ?? 0
1679
        let nextValue = min(max(currentValue + delta, 0), 100)
1680
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1681
    }
1682

            
Bogdan Timofte authored a month ago
1683
    private func syncDraftSelections() {
1684
        guard let selectedChargedDevice else {
1685
            draftChargingTransportMode = nil
1686
            draftChargingStateMode = nil
1687
            return
1688
        }
1689

            
1690
        if let openChargeSession {
1691
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1692
            draftChargingStateMode = openChargeSession.chargingStateMode
1693
            return
1694
        }
1695

            
1696
        if let draftChargingTransportMode,
1697
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1698
            self.draftChargingTransportMode = nil
1699
        }
1700

            
1701
        if let draftChargingStateMode,
1702
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1703
            self.draftChargingStateMode = nil
1704
        }
1705

            
1706
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1707
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1708
        }
1709

            
Bogdan Timofte authored a month ago
1710
        if let draftChargingTransportMode {
1711
            draftChargingStateMode = draftChargingStateMode
1712
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1713
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1714
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1715
        }
Bogdan Timofte authored a month ago
1716
    }
Bogdan Timofte authored a month ago
1717

            
1718
    private struct CompactSelectionOption: Identifiable {
1719
        let id: String
1720
        let title: String
1721
        let isSelected: Bool
1722
        let action: () -> Void
1723
    }
1724

            
1725
    private func compactSelectionMenu(
1726
        title: String,
1727
        options: [CompactSelectionOption]
1728
    ) -> some View {
1729
        Menu {
1730
            ForEach(options) { option in
1731
                Button {
1732
                    option.action()
1733
                } label: {
1734
                    if option.isSelected {
1735
                        Label(option.title, systemImage: "checkmark")
1736
                    } else {
1737
                        Text(option.title)
1738
                    }
1739
                }
1740
            }
1741
        } label: {
1742
            HStack(spacing: 8) {
1743
                Text(title)
1744
                    .foregroundColor(.primary)
1745
                Spacer()
1746
                Image(systemName: "chevron.up.chevron.down")
1747
                    .font(.caption.weight(.semibold))
1748
                    .foregroundColor(.secondary)
1749
            }
1750
            .padding(.horizontal, 12)
1751
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1752
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1753
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1754
        }
1755
        .buttonStyle(.plain)
1756
    }
Bogdan Timofte authored a month ago
1757
}