USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1714 lines | 70.72kb
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 }
207
        guard session.status == .active else { return }
208
        guard session.meterMACAddress == meterMACAddress else { return }
209
        usbMeter.restoreChargeRecordIfNeeded(from: session)
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 {
783
                stopConfirmPanel(for: openChargeSession)
784
            } else {
785
                HStack(spacing: 10) {
786
                    if openChargeSession.status == .active {
787
                        Button("Pause") {
788
                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
789
                        }
790
                        .frame(maxWidth: .infinity)
791
                        .padding(.vertical, 10)
792
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
793
                        .buttonStyle(.plain)
794
                    } else if openChargeSession.status == .paused {
795
                        Button("Resume") {
796
                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
797
                        }
798
                        .frame(maxWidth: .infinity)
799
                        .padding(.vertical, 10)
800
                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
801
                        .buttonStyle(.plain)
802
                    }
Bogdan Timofte authored a month ago
803

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

            
Bogdan Timofte authored a month ago
820
    // MARK: - Battery Gauge Section
821

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

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

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

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

            
873
            batteryProgressBar(
874
                percent: percent,
875
                startPercent: session.startBatteryPercent,
876
                targetPercent: session.targetBatteryPercent
877
            )
878

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

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

            
958
    private func batteryColor(for percent: Double) -> Color {
959
        if percent >= 75 { return .green }
960
        if percent >= 35 { return .orange }
961
        return .red
962
    }
963

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

            
972
    // MARK: - Session Metrics Grid
973

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

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

            
991
            if shouldShowChargingTransport(for: session) {
992
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
993
            }
994
            if shouldShowChargingState(for: session) {
995
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
996
            }
997

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

            
1000
            if let capacity = capacityFallback {
1001
                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
1002
            }
1003
        }
1004
    }
1005

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

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

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

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

            
1049
                Button("Keep Monitoring") {
1050
                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
1051
                }
1052
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
1053
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1054
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1055
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1056
            }
1057
        }
Bogdan Timofte authored a month ago
1058
        .padding(14)
1059
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
1060
    }
1061

            
Bogdan Timofte authored a month ago
1062
    // MARK: - Target Section
1063

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

            
1074
        return HStack(alignment: .center, spacing: 8) {
1075
            Image(systemName: "bell.badge")
1076
                .foregroundColor(.indigo)
1077
                .font(.subheadline)
1078

            
1079
            Text("Notify at")
Bogdan Timofte authored a month ago
1080
                .font(.subheadline.weight(.semibold))
1081

            
Bogdan Timofte authored a month ago
1082
            Spacer(minLength: 8)
1083

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

            
1095
                TextField("—", text: $draftTargetText)
1096
                    .keyboardType(.decimalPad)
1097
                    .textFieldStyle(.roundedBorder)
1098
                    .frame(width: 48)
1099
                    .multilineTextAlignment(.center)
1100
                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
1101

            
1102
                Text("%")
1103
                    .font(.subheadline)
Bogdan Timofte authored a month ago
1104
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1105

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

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

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

            
1139
                Button {
1140
                    showingInlineTargetEditor = false
1141
                    draftTargetText = ""
1142
                } label: {
1143
                    Image(systemName: "xmark.circle")
1144
                        .foregroundColor(.secondary)
1145
                        .font(.title3)
1146
                }
1147
                .buttonStyle(.plain)
1148

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

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

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

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

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

            
1202
    private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
1203
        VStack(alignment: .leading, spacing: 12) {
1204
            Text("Final Checkpoint (optional)")
1205
                .font(.subheadline.weight(.semibold))
1206

            
1207
            HStack(spacing: 8) {
1208
                ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1209
                    Button {
1210
                        finalCheckpointMode = mode
Bogdan Timofte authored a month ago
1211
                        if mode != .custom { finalCheckpointText = "" }
Bogdan Timofte authored a month ago
1212
                    } label: {
1213
                        VStack(spacing: 5) {
1214
                            Image(systemName: mode.icon)
1215
                                .font(.title3)
1216
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1217
                            Text(mode.label)
1218
                                .font(.caption.weight(.semibold))
1219
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1220
                        }
1221
                        .frame(maxWidth: .infinity)
1222
                        .padding(.vertical, 10)
Bogdan Timofte authored a month ago
1223
                        .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear)
Bogdan Timofte authored a month ago
1224
                        .meterCard(
1225
                            tint: finalCheckpointMode == mode ? .primary : .secondary,
1226
                            fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1227
                            strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1228
                            cornerRadius: 12
1229
                        )
1230
                    }
1231
                    .buttonStyle(.plain)
1232
                }
Bogdan Timofte authored a month ago
1233
            }
1234

            
Bogdan Timofte authored a month ago
1235
            if finalCheckpointMode == .custom {
1236
                HStack(spacing: 8) {
1237
                    Button { adjustFinalCheckpoint(by: -1) } label: {
1238
                        Image(systemName: "minus.circle").font(.title3)
1239
                    }
1240
                    .buttonStyle(.plain)
1241

            
1242
                    TextField("—", text: $finalCheckpointText)
1243
                        .keyboardType(.decimalPad)
1244
                        .textFieldStyle(.roundedBorder)
1245
                        .frame(width: 56)
1246
                        .multilineTextAlignment(.center)
1247

            
Bogdan Timofte authored a month ago
1248
                    Text("%").foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1249

            
1250
                    Button { adjustFinalCheckpoint(by: 1) } label: {
1251
                        Image(systemName: "plus.circle").font(.title3)
1252
                    }
1253
                    .buttonStyle(.plain)
1254

            
1255
                    Spacer()
1256
                }
1257
            }
1258

            
Bogdan Timofte authored a month ago
1259
            HStack(spacing: 8) {
1260
                Button("Discard") {
1261
                    _ = appData.deleteChargeSession(sessionID: session.id)
Bogdan Timofte authored a month ago
1262
                    showingStopConfirm = false
1263
                    finalCheckpointText = ""
Bogdan Timofte authored a month ago
1264
                    finalCheckpointMode = .full
Bogdan Timofte authored a month ago
1265
                }
1266
                .frame(maxWidth: .infinity)
1267
                .padding(.vertical, 9)
1268
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1269
                .buttonStyle(.plain)
1270

            
Bogdan Timofte authored a month ago
1271
                let saveDisabled = finalCheckpointMode == .custom
Bogdan Timofte authored a month ago
1272
                    && finalCheckpointText.isEmpty == false
1273
                    && parsedFinalCheckpoint == nil
1274

            
Bogdan Timofte authored a month ago
1275
                Button("Save") {
Bogdan Timofte authored a month ago
1276
                    _ = appData.stopChargeSession(
1277
                        sessionID: session.id,
1278
                        finalBatteryPercent: resolvedFinalCheckpoint
1279
                    )
1280
                    showingStopConfirm = false
1281
                    finalCheckpointText = ""
1282
                    finalCheckpointMode = .full
1283
                }
1284
                .frame(maxWidth: .infinity)
1285
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1286
                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1287
                .buttonStyle(.plain)
1288
                .disabled(saveDisabled)
1289

            
1290
                Button("Cancel") {
1291
                    showingStopConfirm = false
1292
                    finalCheckpointText = ""
1293
                    finalCheckpointMode = .full
1294
                }
1295
                .frame(maxWidth: .infinity)
1296
                .padding(.vertical, 9)
1297
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
Bogdan Timofte authored a month ago
1298
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
1299
            }
1300
        }
1301
        .padding(14)
Bogdan Timofte authored a month ago
1302
        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
Bogdan Timofte authored a month ago
1303
    }
1304

            
Bogdan Timofte authored a month ago
1305
    private var parsedFinalCheckpoint: Double? {
1306
        let normalized = finalCheckpointText
1307
            .trimmingCharacters(in: .whitespacesAndNewlines)
1308
            .replacingOccurrences(of: ",", with: ".")
1309
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1310
        return value
1311
    }
1312

            
1313
    private var resolvedFinalCheckpoint: Double? {
1314
        switch finalCheckpointMode {
1315
        case .full:   return 100.0
1316
        case .skip:   return nil
1317
        case .custom: return parsedFinalCheckpoint
1318
        }
1319
    }
1320

            
1321
    private func adjustFinalCheckpoint(by delta: Double) {
1322
        let current = parsedFinalCheckpoint ?? 0
1323
        let next = min(max(current + delta, 0), 100)
1324
        finalCheckpointText = next.format(decimalDigits: 0)
1325
    }
1326

            
Bogdan Timofte authored a month ago
1327
    // MARK: - Trim Detection Banner
1328

            
1329
    @ViewBuilder
1330
    private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
1331
        if let window = detectedTrimWindow {
1332
            HStack(spacing: 12) {
1333
                Image(systemName: "scissors.circle.fill")
1334
                    .font(.title3)
1335
                    .foregroundColor(.blue)
1336

            
1337
                VStack(alignment: .leading, spacing: 2) {
1338
                    Text("Charging ended early")
1339
                        .font(.subheadline.weight(.semibold))
1340
                    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.")
1341
                        .font(.caption)
1342
                        .foregroundColor(.secondary)
1343
                        .fixedSize(horizontal: false, vertical: true)
1344
                }
1345

            
1346
                Spacer(minLength: 0)
1347

            
1348
                VStack(spacing: 6) {
1349
                    Button("Apply") {
1350
                        _ = appData.setSessionTrim(
1351
                            sessionID: session.id,
1352
                            start: window.start,
1353
                            end: window.end
1354
                        )
1355
                        trimBannerDismissedForSessionID = session.id
1356
                    }
1357
                    .font(.caption.weight(.semibold))
1358
                    .buttonStyle(.borderedProminent)
1359
                    .controlSize(.small)
1360
                    .tint(.blue)
1361

            
1362
                    Button {
1363
                        trimBannerDismissedForSessionID = session.id
1364
                    } label: {
1365
                        Image(systemName: "xmark")
1366
                            .font(.caption2.weight(.semibold))
1367
                            .foregroundColor(.secondary)
1368
                    }
1369
                    .buttonStyle(.plain)
1370
                }
1371
            }
1372
            .padding(14)
1373
            .background(
1374
                RoundedRectangle(cornerRadius: 14)
1375
                    .fill(Color.blue.opacity(0.10))
1376
                    .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1))
1377
            )
1378
            .transition(.opacity.combined(with: .move(edge: .top)))
1379
        }
1380
    }
1381

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

            
1385
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1386
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1387
                Image(systemName: "chart.xyaxis.line")
1388
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1389
                Text("Session Chart")
1390
                    .font(.headline)
1391
                ContextInfoButton(
1392
                    title: "Session Chart",
Bogdan Timofte authored a month ago
1393
                    message: usesChargeRecordBuffer(for: session)
1394
                        ? "This chart combines the persisted session curve with current live data from this meter."
1395
                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
Bogdan Timofte authored a month ago
1396
                )
Bogdan Timofte authored a month ago
1397
                Spacer(minLength: 0)
Bogdan Timofte authored a month ago
1398
            }
Bogdan Timofte authored a month ago
1399

            
Bogdan Timofte authored a month ago
1400
            MeasurementChartView(
1401
                timeRange: timeRange,
1402
                timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1403
                timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1404
                showsRangeSelector: hasRangeSelector,
1405
                rebasesEnergyToVisibleRangeStart: true,
1406
                extendsTimelineToPresent: false,
1407
                rangeSelectorConfiguration: hasRangeSelector
1408
                    ? MeasurementChartRangeSelectorConfiguration(
1409
                        keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1410
                            title: "Keep Selection",
1411
                            shortTitle: "Keep",
Bogdan Timofte authored a month ago
1412
                            systemName: "scissors",
1413
                            tone: .destructive,
1414
                            handler: { range in
1415
                                _ = appData.setSessionTrim(
1416
                                    sessionID: session.id,
1417
                                    start: range.lowerBound,
1418
                                    end: range.upperBound
1419
                                )
1420
                                trimBannerDismissedForSessionID = session.id
1421
                            }
1422
                        ),
1423
                        removeAction: nil,
1424
                        resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1425
                            title: "Reset Trim",
1426
                            shortTitle: "Reset",
Bogdan Timofte authored a month ago
1427
                            systemName: "arrow.counterclockwise",
1428
                            tone: .reversible,
1429
                            confirmationTitle: "Reset session trim?",
1430
                            confirmationButtonTitle: "Reset trim",
1431
                            handler: {
1432
                                _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1433
                            }
Bogdan Timofte authored a month ago
1434
                        )
Bogdan Timofte authored a month ago
1435
                    )
1436
                    : nil
1437
            )
1438
            .environmentObject(usbMeter.chargeRecordMeasurements)
1439
            .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
1440
        }
1441
        .padding(18)
1442
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1443
    }
1444

            
1445
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1446
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1447
            HStack(spacing: 8) {
1448
                Text("Meter Recorder")
1449
                    .font(.headline)
1450

            
1451
                Spacer(minLength: 0)
1452

            
1453
                Button {
1454
                    showsMeterTotalsInfo.toggle()
1455
                } label: {
1456
                    Image(systemName: "info.circle")
1457
                        .font(.body.weight(.semibold))
1458
                        .foregroundColor(.secondary)
1459
                }
1460
                .buttonStyle(.plain)
1461
                .accessibilityLabel("Meter recorder info")
1462
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
1463
                    VStack(alignment: .leading, spacing: 10) {
1464
                        Text("Meter Recorder")
1465
                            .font(.headline)
1466
                        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.")
1467
                            .font(.body)
1468
                            .fixedSize(horizontal: false, vertical: true)
1469
                    }
1470
                    .padding(16)
1471
                    .frame(width: 280, alignment: .leading)
1472
                }
1473
            }
Bogdan Timofte authored a month ago
1474

            
1475
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
1476
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1477
                values: [
1478
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1479
                    usbMeter.recordingDurationDescription,
1480
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1481
                ]
1482
            )
Bogdan Timofte authored a month ago
1483

            
1484
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1485
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1486
                    .font(.caption)
1487
                    .foregroundColor(.secondary)
1488
            }
Bogdan Timofte authored a month ago
1489
        }
1490
        .padding(18)
1491
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1492
    }
1493

            
Bogdan Timofte authored a month ago
1494
    // MARK: - Helpers
1495

            
1496
    private func setupRow<Content: View>(
1497
        icon: String,
1498
        iconColor: Color = .secondary,
1499
        @ViewBuilder content: () -> Content
1500
    ) -> some View {
1501
        HStack(spacing: 10) {
1502
            Image(systemName: icon)
1503
                .foregroundColor(iconColor)
1504
                .font(.body.weight(.medium))
1505
                .frame(width: 22, alignment: .center)
1506
            content()
1507
        }
1508
        .padding(.horizontal, 14)
1509
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1510
    }
1511

            
1512
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1513
        if session.autoStopEnabled == false {
1514
            return "Manual"
1515
        }
1516
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1517
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1518
        }
1519
        if session.stopThresholdAmps > 0 {
1520
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1521
        }
1522
        return "Learning"
1523
    }
1524

            
Bogdan Timofte authored a month ago
1525
    private func sessionMetricRows(
1526
        for session: ChargeSessionSummary,
1527
        displayedEnergyWh: Double
1528
    ) -> [SessionMetricRow] {
1529
        var rows: [SessionMetricRow] = []
1530

            
1531
        if shouldShowChargingTransport(for: session) {
1532
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1533
        }
1534

            
1535
        if shouldShowChargingState(for: session) {
1536
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1537
        }
1538

            
1539
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
Bogdan Timofte authored a month ago
1540
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
Bogdan Timofte authored a month ago
1541
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1542
        return rows
1543
    }
1544

            
1545
    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1546
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1547
        return selectedChargedDevice.supportedChargingModes.count > 1
1548
            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1549
    }
1550

            
1551
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1552
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1553
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1554
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1555
    }
1556

            
Bogdan Timofte authored a month ago
1557
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1558
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1559
        guard session.isTrimmed == false else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1560
        guard session.status.isOpen else { return storedEnergyWh }
1561
        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1562
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1563
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1564
        }
1565
        return storedEnergyWh
1566
    }
1567

            
Bogdan Timofte authored a month ago
1568
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1569
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1570
        guard session.isTrimmed == false else { return storedChargeAh }
Bogdan Timofte authored a month ago
1571
        guard session.status.isOpen else { return storedChargeAh }
1572
        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
Bogdan Timofte authored a month ago
1573
        if let baselineChargeAh = session.meterChargeBaselineAh {
1574
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1575
        }
1576
        return storedChargeAh
1577
    }
1578

            
Bogdan Timofte authored a month ago
1579
    private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
1580
        let storedDuration = max(session.effectiveDuration, 0)
1581
        guard session.isTrimmed == false else { return storedDuration }
1582
        guard session.status.isOpen else { return storedDuration }
1583
        guard session.meterMACAddress == meterMACAddress else { return storedDuration }
1584
        return max(storedDuration, max(usbMeter.chargeRecordDuration, 0))
1585
    }
1586

            
Bogdan Timofte authored a month ago
1587
    private func formatDuration(_ duration: TimeInterval) -> String {
1588
        let totalSeconds = Int(duration.rounded(.down))
1589
        let hours = totalSeconds / 3600
1590
        let minutes = (totalSeconds % 3600) / 60
1591
        let seconds = totalSeconds % 60
1592
        if hours > 0 {
1593
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1594
        }
1595
        return String(format: "%02d:%02d", minutes, seconds)
1596
    }
1597

            
Bogdan Timofte authored a month ago
1598
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1599
        guard session.chargingTransportMode == .wireless,
1600
              let chargerID = session.chargerID,
1601
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1602
            return nil
1603
        }
Bogdan Timofte authored a month ago
1604
        guard charger.chargerIdleCurrentAmps == nil else { return nil }
Bogdan Timofte authored a month ago
1605
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1606
    }
1607

            
1608
    private func startSession() {
1609
        guard let selectedChargedDevice,
1610
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1611
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1612
            return
1613
        }
1614

            
1615
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1616
        let didStart = appData.startChargeSession(
1617
            for: usbMeter,
1618
            chargedDeviceID: selectedChargedDevice.id,
1619
            chargerID: chargerID,
1620
            chargingTransportMode: chargingTransportMode,
1621
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1622
            autoStopEnabled: false,
1623
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1624
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1625
        )
Bogdan Timofte authored a month ago
1626

            
1627
        if didStart {
1628
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1629
            initialCheckpointMode = .known
1630
        }
1631
    }
1632

            
1633
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1634
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1635
        let currentValue = initialCheckpointValue ?? 0
1636
        let nextValue = min(max(currentValue + delta, 0), 100)
1637
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1638
    }
1639

            
Bogdan Timofte authored a month ago
1640
    private func syncDraftSelections() {
1641
        guard let selectedChargedDevice else {
1642
            draftChargingTransportMode = nil
1643
            draftChargingStateMode = nil
1644
            return
1645
        }
1646

            
1647
        if let openChargeSession {
1648
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1649
            draftChargingStateMode = openChargeSession.chargingStateMode
1650
            return
1651
        }
1652

            
1653
        if let draftChargingTransportMode,
1654
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1655
            self.draftChargingTransportMode = nil
1656
        }
1657

            
1658
        if let draftChargingStateMode,
1659
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1660
            self.draftChargingStateMode = nil
1661
        }
1662

            
1663
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1664
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1665
        }
1666

            
Bogdan Timofte authored a month ago
1667
        if let draftChargingTransportMode {
1668
            draftChargingStateMode = draftChargingStateMode
1669
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1670
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1671
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1672
        }
Bogdan Timofte authored a month ago
1673
    }
Bogdan Timofte authored a month ago
1674

            
1675
    private struct CompactSelectionOption: Identifiable {
1676
        let id: String
1677
        let title: String
1678
        let isSelected: Bool
1679
        let action: () -> Void
1680
    }
1681

            
1682
    private func compactSelectionMenu(
1683
        title: String,
1684
        options: [CompactSelectionOption]
1685
    ) -> some View {
1686
        Menu {
1687
            ForEach(options) { option in
1688
                Button {
1689
                    option.action()
1690
                } label: {
1691
                    if option.isSelected {
1692
                        Label(option.title, systemImage: "checkmark")
1693
                    } else {
1694
                        Text(option.title)
1695
                    }
1696
                }
1697
            }
1698
        } label: {
1699
            HStack(spacing: 8) {
1700
                Text(title)
1701
                    .foregroundColor(.primary)
1702
                Spacer()
1703
                Image(systemName: "chevron.up.chevron.down")
1704
                    .font(.caption.weight(.semibold))
1705
                    .foregroundColor(.secondary)
1706
            }
1707
            .padding(.horizontal, 12)
1708
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1709
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1710
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1711
        }
1712
        .buttonStyle(.plain)
1713
    }
Bogdan Timofte authored a month ago
1714
}