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

            
45
    private enum SessionStartRequirement: Identifiable {
46
        case existingSession
47
        case device
48
        case chargingType
49
        case chargingMode
50
        case charger
51
        case initialCheckpointEmpty
52
        case initialCheckpointInvalid
53

            
54
        var id: String {
55
            switch self {
Bogdan Timofte authored a month ago
56
            case .existingSession:         return "existing-session"
57
            case .device:                  return "device"
58
            case .chargingType:            return "charging-type"
59
            case .chargingMode:            return "charging-mode"
60
            case .charger:                 return "charger"
61
            case .initialCheckpointEmpty:  return "initial-checkpoint-empty"
62
            case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
Bogdan Timofte authored a month ago
63
            }
64
        }
65

            
66
        var message: String {
67
            switch self {
Bogdan Timofte authored a month ago
68
            case .existingSession:          return "Stop or pause the current session before starting another one."
69
            case .device:                   return "Select the device that is charging."
70
            case .chargingType:             return "Choose the charging type for this session."
71
            case .chargingMode:             return "Choose whether the device is on or off for this session."
72
            case .charger:                  return "Select the wireless charger used in this session."
73
            case .initialCheckpointEmpty:   return "Enter the initial battery percentage."
74
            case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
75
            }
76
        }
77
    }
78

            
79
    private enum FinalCheckpoint: Hashable {
80
        case full
81
        case skip
82
        case custom
83

            
84
        var label: String {
85
            switch self {
86
            case .full:   return "Full"
87
            case .skip:   return "Skip"
88
            case .custom: return "Other %"
89
            }
90
        }
91

            
92
        var icon: String {
93
            switch self {
94
            case .full:   return "battery.100percent"
95
            case .skip:   return "minus.circle"
96
            case .custom: return "pencil"
Bogdan Timofte authored a month ago
97
            }
98
        }
99
    }
Bogdan Timofte authored a month ago
100

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

            
104
    @State private var showingInlineTargetEditor = false
105
    @State private var draftTargetText = ""
106
    @State private var showingStopConfirm = false
107
    @State private var finalCheckpointMode: FinalCheckpoint = .full
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)
708
                    Button("Stop Session") {
709
                        finalCheckpointMode = .skip
710
                        finalCheckpointText = ""
711
                        _ = appData.stopChargeSession(
712
                            sessionID: openChargeSession.id,
713
                            finalBatteryPercent: nil
714
                        )
715
                    }
716
                    .frame(maxWidth: .infinity)
717
                    .padding(.vertical, 9)
718
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
719
                    .buttonStyle(.plain)
720
                }
721
                .padding(14)
722
                .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
723
            }
724

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
822
    // MARK: - Battery Gauge Section
823

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

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

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

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

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

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

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

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

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

            
974
    // MARK: - Session Metrics Grid
975

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1064
    // MARK: - Target Section
1065

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

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

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

            
Bogdan Timofte authored a month ago
1084
            Spacer(minLength: 8)
1085

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1258
                    Text("%")
1259
                        .foregroundColor(.secondary)
1260

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

            
1266
                    Spacer()
1267
                }
1268
            }
1269

            
1270
            // Action row
1271
            HStack(spacing: 10) {
1272
                Button("Cancel") {
1273
                    showingStopConfirm = false
1274
                    finalCheckpointText = ""
1275
                }
1276
                .frame(maxWidth: .infinity)
1277
                .padding(.vertical, 9)
1278
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1279
                .buttonStyle(.plain)
1280

            
1281
                let stopDisabled = finalCheckpointMode == .custom
1282
                    && finalCheckpointText.isEmpty == false
1283
                    && parsedFinalCheckpoint == nil
1284

            
1285
                Button("Stop Session") {
1286
                    _ = appData.stopChargeSession(
1287
                        sessionID: session.id,
1288
                        finalBatteryPercent: resolvedFinalCheckpoint
1289
                    )
1290
                    showingStopConfirm = false
1291
                    finalCheckpointText = ""
1292
                    finalCheckpointMode = .full
1293
                }
1294
                .frame(maxWidth: .infinity)
1295
                .padding(.vertical, 9)
1296
                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1297
                .buttonStyle(.plain)
1298
                .disabled(stopDisabled)
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
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1384
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1385
                Image(systemName: "chart.xyaxis.line")
1386
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1387
                Text("Session Chart")
1388
                    .font(.headline)
1389
                ContextInfoButton(
1390
                    title: "Session Chart",
Bogdan Timofte authored a month ago
1391
                    message: usesChargeRecordBuffer(for: session)
1392
                        ? "This chart combines the persisted session curve with current live data from this meter."
1393
                        : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging."
Bogdan Timofte authored a month ago
1394
                )
Bogdan Timofte authored a month ago
1395
                Spacer(minLength: 0)
Bogdan Timofte authored a month ago
1396
            }
Bogdan Timofte authored a month ago
1397

            
Bogdan Timofte authored a month ago
1398
            GeometryReader { geometry in
1399
                let chartWidth = max(geometry.size.width, 1)
1400
                let compactChartLayout = chartWidth < 760
1401
                let chartHeight = compactChartLayout ? 290.0 : 350.0
1402

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

            
1450
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1451
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1452
            HStack(spacing: 8) {
1453
                Text("Meter Recorder")
1454
                    .font(.headline)
1455

            
1456
                Spacer(minLength: 0)
1457

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

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

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

            
Bogdan Timofte authored a month ago
1499
    // MARK: - Helpers
1500

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

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

            
Bogdan Timofte authored a month ago
1530
    private func sessionMetricRows(
1531
        for session: ChargeSessionSummary,
1532
        displayedEnergyWh: Double
1533
    ) -> [SessionMetricRow] {
1534
        var rows: [SessionMetricRow] = []
1535

            
1536
        if shouldShowChargingTransport(for: session) {
1537
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1538
        }
1539

            
1540
        if shouldShowChargingState(for: session) {
1541
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1542
        }
1543

            
1544
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
Bogdan Timofte authored a month ago
1545
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session))))
Bogdan Timofte authored a month ago
1546
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1547
        return rows
1548
    }
1549

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

            
1556
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1557
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1558
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1559
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1560
    }
1561

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

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

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

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

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

            
1613
    private func startSession() {
1614
        guard let selectedChargedDevice,
1615
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1616
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1617
            return
1618
        }
1619

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

            
1632
        if didStart {
1633
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1634
            initialCheckpointMode = .known
1635
        }
1636
    }
1637

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

            
Bogdan Timofte authored a month ago
1645
    private func syncDraftSelections() {
1646
        guard let selectedChargedDevice else {
1647
            draftChargingTransportMode = nil
1648
            draftChargingStateMode = nil
1649
            return
1650
        }
1651

            
1652
        if let openChargeSession {
1653
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1654
            draftChargingStateMode = openChargeSession.chargingStateMode
1655
            return
1656
        }
1657

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

            
1663
        if let draftChargingStateMode,
1664
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1665
            self.draftChargingStateMode = nil
1666
        }
1667

            
1668
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1669
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1670
        }
1671

            
Bogdan Timofte authored a month ago
1672
        if let draftChargingTransportMode {
1673
            draftChargingStateMode = draftChargingStateMode
1674
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1675
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1676
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1677
        }
Bogdan Timofte authored a month ago
1678
    }
Bogdan Timofte authored a month ago
1679

            
1680
    private struct CompactSelectionOption: Identifiable {
1681
        let id: String
1682
        let title: String
1683
        let isSelected: Bool
1684
        let action: () -> Void
1685
    }
1686

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