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

            
13
    @EnvironmentObject private var appData: AppData
14

            
15
    @State private var chargedDeviceLibraryVisibility = false
16
    @State private var chargerLibraryVisibility = false
17
    @State private var deviceLibraryMACAddress = ""
18
    @State private var chargerLibraryMACAddress = ""
19
    @State private var chargedDeviceLibraryTint: Color = .orange
20
    @State private var chargerLibraryTint: Color = .pink
21

            
Bogdan Timofte authored a month ago
22
    var body: some View {
Bogdan Timofte authored a month ago
23
        MeterChargeRecordContentView(
24
            onSelectDevice: { mac, tint in
25
                deviceLibraryMACAddress = mac
26
                chargedDeviceLibraryTint = tint
27
                chargedDeviceLibraryVisibility = true
28
            },
29
            onSelectCharger: { mac, tint in
30
                chargerLibraryMACAddress = mac
31
                chargerLibraryTint = tint
32
                chargerLibraryVisibility = true
33
            }
34
        )
35
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
36
            ChargedDeviceLibrarySheetView(
37
                meterMACAddress: deviceLibraryMACAddress,
38
                meterTint: chargedDeviceLibraryTint,
39
                mode: .device
40
            )
41
            .environmentObject(appData)
42
        }
43
        .sheet(isPresented: $chargerLibraryVisibility) {
44
            ChargedDeviceLibrarySheetView(
45
                meterMACAddress: chargerLibraryMACAddress,
46
                meterTint: chargerLibraryTint,
47
                mode: .charger
48
            )
49
            .environmentObject(appData)
50
        }
Bogdan Timofte authored a month ago
51
    }
52
}
53

            
54
struct MeterChargeRecordContentView: View {
Bogdan Timofte authored a month ago
55
    private struct SessionMetricRow {
56
        let label: String
57
        let value: String
58
    }
59

            
Bogdan Timofte authored a month ago
60
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
61
        case known
62
        case unknown
63
        case flat
64

            
65
        var id: String { rawValue }
66

            
67
        var title: String {
68
            switch self {
Bogdan Timofte authored a month ago
69
            case .known:   return "Known"
70
            case .unknown: return "Unknown"
71
            case .flat:    return "Flat"
Bogdan Timofte authored a month ago
72
            }
73
        }
74
    }
75

            
Bogdan Timofte authored a month ago
76
    private enum ActiveMode: Hashable {
77
        case chargeSession
78
        case standbyPower
79
    }
Bogdan Timofte authored a month ago
80

            
81
    private enum SessionStartRequirement: Identifiable {
82
        case existingSession
83
        case device
84
        case chargingType
85
        case chargingMode
86
        case charger
87
        case initialCheckpointEmpty
88
        case initialCheckpointInvalid
89

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

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

            
115
    private enum FinalCheckpoint: Hashable {
116
        case full
117
        case skip
118
        case custom
119

            
120
        var label: String {
121
            switch self {
122
            case .full:   return "Full"
123
            case .skip:   return "Skip"
124
            case .custom: return "Other %"
125
            }
126
        }
127

            
128
        var icon: String {
129
            switch self {
130
            case .full:   return "battery.100percent"
131
            case .skip:   return "minus.circle"
132
            case .custom: return "pencil"
Bogdan Timofte authored a month ago
133
            }
134
        }
135
    }
Bogdan Timofte authored a month ago
136

            
Bogdan Timofte authored a month ago
137
    let onSelectDevice: (String, Color) -> Void
138
    let onSelectCharger: (String, Color) -> Void
139

            
Bogdan Timofte authored a month ago
140
    @EnvironmentObject private var appData: AppData
141
    @EnvironmentObject private var usbMeter: Meter
142

            
143
    @State private var showingInlineTargetEditor = false
144
    @State private var draftTargetText = ""
145
    @State private var showingStopConfirm = false
146
    @State private var finalCheckpointMode: FinalCheckpoint = .full
147
    @State private var finalCheckpointText = ""
148
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
149
    @State private var draftChargingTransportMode: ChargingTransportMode?
150
    @State private var draftChargingStateMode: ChargingStateMode?
151
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
152
    @State private var initialCheckpoint = ""
153
    @State private var showsMeterTotalsInfo = false
154
    @State private var activeMode: ActiveMode = .chargeSession
155

            
Bogdan Timofte authored a month ago
156
    var body: some View {
157
        ScrollView {
Bogdan Timofte authored a month ago
158
            VStack(spacing: 14) {
159
                statusHeader
Bogdan Timofte authored a month ago
160

            
Bogdan Timofte authored a month ago
161
                if let openChargeSession {
162
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
163

            
Bogdan Timofte authored a month ago
164
                    if showsMeterTotalsCard {
165
                        meterTotalsCard
166
                    }
167

            
Bogdan Timofte authored a month ago
168
                    if let range = sessionChartTimeRange {
169
                        sessionChartCard(timeRange: range, session: openChargeSession)
170
                    }
171
                } else {
172
                    modePicker
173

            
174
                    switch activeMode {
175
                    case .chargeSession:
176
                        chargeSessionSetupCard
177
                    case .standbyPower:
178
                        standbyPowerCard
179
                    }
180

            
181
                    if showsMeterTotalsCard {
182
                        meterTotalsCard
Bogdan Timofte authored a month ago
183
                    }
184
                }
185
            }
186
            .padding()
187
        }
188
        .background(
189
            LinearGradient(
190
                colors: [.pink.opacity(0.14), Color.clear],
191
                startPoint: .topLeading,
192
                endPoint: .bottomTrailing
193
            )
194
            .ignoresSafeArea()
195
        )
Bogdan Timofte authored a month ago
196
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
197
            Alert(
198
                title: Text("Delete Battery Checkpoint"),
199
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
200
                primaryButton: .destructive(Text("Delete")) {
201
                    if let openChargeSession {
202
                        _ = appData.deleteBatteryCheckpoint(
203
                            checkpointID: checkpoint.id,
204
                            for: openChargeSession.id
205
                        )
206
                    }
207
                },
208
                secondaryButton: .cancel()
209
            )
210
        }
Bogdan Timofte authored a month ago
211
        .onAppear {
212
            syncDraftSelections()
213
        }
214
        .onChange(of: selectedChargedDevice?.id) { _ in
215
            syncDraftSelections()
216
        }
217
        .onChange(of: openChargeSession?.id) { _ in
218
            syncDraftSelections()
Bogdan Timofte authored a month ago
219
            showingInlineTargetEditor = false
220
            draftTargetText = ""
Bogdan Timofte authored a month ago
221
        }
Bogdan Timofte authored a month ago
222
    }
223

            
Bogdan Timofte authored a month ago
224
    // MARK: - Computed Properties
225

            
Bogdan Timofte authored a month ago
226
    private var meterMACAddress: String {
227
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
228
    }
229

            
Bogdan Timofte authored a month ago
230
    private var selectedChargedDevice: ChargedDeviceSummary? {
231
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
232
    }
233

            
234
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
235
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
236
    }
237

            
Bogdan Timofte authored a month ago
238
    private var openChargeSession: ChargeSessionSummary? {
239
        appData.activeChargeSessionSummary(for: meterMACAddress)
240
    }
241

            
Bogdan Timofte authored a month ago
242
    private var showsMeterTotalsCard: Bool {
243
        usbMeter.supportsRecordingView
244
            || usbMeter.supportsDataGroupCommands
245
            || usbMeter.recordedAH > 0
246
            || usbMeter.recordedWH > 0
247
            || usbMeter.recordingDuration > 0
248
    }
249

            
Bogdan Timofte authored a month ago
250
    private var selectedDraftTransportMode: ChargingTransportMode? {
251
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
252
    }
253

            
254
    private var selectedDraftChargingStateMode: ChargingStateMode? {
255
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
256
    }
257

            
Bogdan Timofte authored a month ago
258
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
259
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
260
        let normalized = initialCheckpoint
261
            .trimmingCharacters(in: .whitespacesAndNewlines)
262
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
263
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
264
        return value
265
    }
266

            
Bogdan Timofte authored a month ago
267
    private var hasInitialCheckpointInput: Bool {
268
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
269
    }
270

            
271
    private var shouldRequireInitialCheckpoint: Bool {
272
        initialCheckpointMode == .known
273
    }
274

            
Bogdan Timofte authored a month ago
275
    private var requiresExplicitTransportSelection: Bool {
276
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
277
    }
278

            
279
    private var requiresExplicitChargingStateSelection: Bool {
280
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
281
    }
282

            
Bogdan Timofte authored a month ago
283
    private var startRequirements: [SessionStartRequirement] {
284
        var requirements: [SessionStartRequirement] = []
285

            
286
        if openChargeSession != nil {
287
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
288
        }
289

            
Bogdan Timofte authored a month ago
290
        guard let selectedChargedDevice else {
291
            requirements.append(.device)
292
            return requirements
Bogdan Timofte authored a month ago
293
        }
294

            
Bogdan Timofte authored a month ago
295
        guard let chargingTransportMode = selectedDraftTransportMode else {
296
            requirements.append(.chargingType)
297
            return requirements
Bogdan Timofte authored a month ago
298
        }
299

            
Bogdan Timofte authored a month ago
300
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
301
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
302
        }
303

            
Bogdan Timofte authored a month ago
304
        guard let chargingStateMode = selectedDraftChargingStateMode else {
305
            requirements.append(.chargingMode)
306
            return requirements
307
        }
308

            
309
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
310
            requirements.append(.chargingMode)
311
        }
312

            
313
        if chargingTransportMode == .wireless, selectedCharger == nil {
314
            requirements.append(.charger)
315
        }
316

            
317
        if shouldRequireInitialCheckpoint {
318
            if hasInitialCheckpointInput == false {
319
                requirements.append(.initialCheckpointEmpty)
320
            } else if initialCheckpointValue == nil {
321
                requirements.append(.initialCheckpointInvalid)
322
            }
323
        }
324

            
325
        return requirements
326
    }
327

            
328
    private var canStartSession: Bool {
329
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
330
    }
331

            
332
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
333
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
334
        return openChargeSession.status.title
335
    }
336

            
337
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
338
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
339
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
340
        case .active:    return .red
341
        case .paused:    return .orange
342
        case .completed: return .green
343
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
344
        }
345
    }
346

            
347
    private var sessionChartTimeRange: ClosedRange<Date>? {
Bogdan Timofte authored a month ago
348
        guard let openChargeSession else { return nil }
Bogdan Timofte authored a month ago
349
        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
350
        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
351
    }
352

            
Bogdan Timofte authored a month ago
353
    private var showsWirelessChargerSection: Bool {
354
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
355
        return transportMode == .wireless
356
    }
Bogdan Timofte authored a month ago
357

            
Bogdan Timofte authored a month ago
358
    // MARK: - Status Header
359

            
360
    private var statusHeader: some View {
361
        HStack {
362
            Image(systemName: "bolt.fill")
363
                .foregroundColor(.pink)
364
            Text("Charging Session")
365
                .font(.system(.title3, design: .rounded).weight(.bold))
366
            Spacer()
367
            Text(headerStatusTitle)
368
                .font(.caption.weight(.bold))
369
                .foregroundColor(headerStatusColor)
370
                .padding(.horizontal, 10)
371
                .padding(.vertical, 6)
372
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
373
        }
374
        .padding(.horizontal, 18)
375
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
376
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
377
    }
378

            
Bogdan Timofte authored a month ago
379
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
380

            
Bogdan Timofte authored a month ago
381
    private var modePicker: some View {
382
        Picker("", selection: $activeMode) {
383
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
384
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
385
        }
Bogdan Timofte authored a month ago
386
        .pickerStyle(.segmented)
387
        .labelsHidden()
Bogdan Timofte authored a month ago
388
    }
389

            
Bogdan Timofte authored a month ago
390
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
391

            
Bogdan Timofte authored a month ago
392
    private var chargeSessionSetupCard: some View {
393
        VStack(alignment: .leading, spacing: 0) {
394
            // Device
395
            setupRow(icon: "iphone", iconColor: .blue) {
396
                if let device = selectedChargedDevice {
397
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
398
                        .font(.subheadline.weight(.semibold))
399
                } else {
400
                    Text("No device selected")
Bogdan Timofte authored a month ago
401
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
402
                        .font(.subheadline)
Bogdan Timofte authored a month ago
403
                }
Bogdan Timofte authored a month ago
404
                Spacer(minLength: 8)
405
                Button(selectedChargedDevice == nil ? "Select" : "Change") {
Bogdan Timofte authored a month ago
406
                    onSelectDevice(meterMACAddress, usbMeter.color)
Bogdan Timofte authored a month ago
407
                }
408
                .font(.caption.weight(.semibold))
409
                .buttonStyle(.bordered)
410
                .controlSize(.small)
Bogdan Timofte authored a month ago
411
            }
412

            
Bogdan Timofte authored a month ago
413
            // Charging type — only when device supports multiple
414
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
415
                Divider().padding(.leading, 46)
416
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
417
                    Text("Type")
418
                        .foregroundColor(.secondary)
419
                        .font(.subheadline)
420
                    Spacer()
Bogdan Timofte authored a month ago
421
                    compactSelectionMenu(
422
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
423
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
424
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
425
                                id: mode.id, title: mode.title,
426
                                isSelected: draftChargingTransportMode == mode,
427
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
428
                            )
Bogdan Timofte authored a month ago
429
                        }
Bogdan Timofte authored a month ago
430
                    )
Bogdan Timofte authored a month ago
431
                }
Bogdan Timofte authored a month ago
432
            }
Bogdan Timofte authored a month ago
433

            
Bogdan Timofte authored a month ago
434
            // Charging state — only when device supports multiple
435
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
436
                Divider().padding(.leading, 46)
437
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
438
                    Text("Mode")
439
                        .foregroundColor(.secondary)
440
                        .font(.subheadline)
441
                    Spacer()
Bogdan Timofte authored a month ago
442
                    compactSelectionMenu(
443
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
444
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
445
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
446
                                id: mode.id, title: mode.title,
447
                                isSelected: draftChargingStateMode == mode,
448
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
449
                            )
Bogdan Timofte authored a month ago
450
                        }
Bogdan Timofte authored a month ago
451
                    )
Bogdan Timofte authored a month ago
452
                }
453
            }
Bogdan Timofte authored a month ago
454

            
Bogdan Timofte authored a month ago
455
            // Wireless charger — only when wireless transport
456
            if showsWirelessChargerSection {
457
                Divider().padding(.leading, 46)
458
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
459
                    if let charger = selectedCharger {
460
                        ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
461
                            .font(.subheadline.weight(.semibold))
462
                        if charger.chargerIdleCurrentAmps == nil {
463
                            Image(systemName: "exclamationmark.triangle.fill")
464
                                .foregroundColor(.orange)
465
                                .font(.caption)
466
                        }
467
                    } else {
468
                        Text("No charger selected")
469
                            .foregroundColor(.secondary)
470
                            .font(.subheadline)
471
                    }
472
                    Spacer(minLength: 8)
473
                    Button(selectedCharger == nil ? "Select" : "Change") {
Bogdan Timofte authored a month ago
474
                        onSelectCharger(meterMACAddress, usbMeter.color)
Bogdan Timofte authored a month ago
475
                    }
Bogdan Timofte authored a month ago
476
                    .font(.caption.weight(.semibold))
477
                    .buttonStyle(.bordered)
478
                    .controlSize(.small)
Bogdan Timofte authored a month ago
479
                }
Bogdan Timofte authored a month ago
480
            }
481

            
Bogdan Timofte authored a month ago
482
            // Battery checkpoint
483
            Divider().padding(.leading, 46)
484
            setupRow(icon: "battery.75percent", iconColor: .green) {
485
                if initialCheckpointMode == .known {
486
                    Button { adjustInitialCheckpoint(by: -1) } label: {
487
                        Image(systemName: "minus.circle").font(.title3)
488
                    }
489
                    .buttonStyle(.plain)
490

            
491
                    TextField("—", text: $initialCheckpoint)
492
                        .keyboardType(.decimalPad)
493
                        .textFieldStyle(.roundedBorder)
494
                        .frame(width: 52)
495
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
496

            
Bogdan Timofte authored a month ago
497
                    Text("%")
498
                        .font(.subheadline)
499
                        .foregroundColor(.secondary)
500

            
501
                    Button { adjustInitialCheckpoint(by: 1) } label: {
502
                        Image(systemName: "plus.circle").font(.title3)
503
                    }
504
                    .buttonStyle(.plain)
505
                } else {
506
                    Text(initialCheckpointMode == .flat
507
                         ? "Flat (device off / discharged)"
508
                         : "Unknown")
509
                        .font(.subheadline)
510
                        .foregroundColor(.secondary)
511
                }
512
                Spacer()
Bogdan Timofte authored a month ago
513
                compactSelectionMenu(
514
                    title: initialCheckpointMode.title,
515
                    options: InitialCheckpointMode.allCases.map { mode in
516
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
517
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
518
                            isSelected: initialCheckpointMode == mode,
519
                            action: { initialCheckpointMode = mode }
520
                        )
521
                    }
522
                )
Bogdan Timofte authored a month ago
523
            }
524

            
Bogdan Timofte authored a month ago
525
            // Requirement errors
526
            if startRequirements.isEmpty == false {
527
                Divider()
528
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
529
                    ForEach(startRequirements) { requirement in
530
                        Label(requirement.message, systemImage: "exclamationmark.circle")
531
                            .font(.caption)
532
                            .foregroundColor(.orange)
533
                    }
534
                }
Bogdan Timofte authored a month ago
535
                .padding(.horizontal, 14)
536
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
537
            }
Bogdan Timofte authored a month ago
538

            
Bogdan Timofte authored a month ago
539
            // Start button
540
            Divider()
Bogdan Timofte authored a month ago
541
            Button("Start Session") {
542
                startSession()
543
            }
544
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
545
            .padding(.vertical, 11)
546
            .font(.subheadline.weight(.semibold))
547
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
548
            .buttonStyle(.plain)
549
            .disabled(!canStartSession)
550
        }
Bogdan Timofte authored a month ago
551
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
552
    }
553

            
Bogdan Timofte authored a month ago
554
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
555

            
Bogdan Timofte authored a month ago
556
    private var standbyPowerCard: some View {
557
        VStack(alignment: .leading, spacing: 12) {
558
            HStack(spacing: 10) {
559
                Image(systemName: "powersleep")
560
                    .foregroundColor(.orange)
561
                    .font(.title3)
562
                VStack(alignment: .leading, spacing: 2) {
563
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
564
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
565
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
566
                        .font(.caption)
567
                        .foregroundColor(.secondary)
568
                }
Bogdan Timofte authored a month ago
569
            }
Bogdan Timofte authored a month ago
570

            
Bogdan Timofte authored a month ago
571
            NavigationLink(
572
                destination: ChargerStandbyPowerWizardView(
573
                    preferredMeterMACAddress: meterMACAddress
574
                )
575
            ) {
576
                HStack {
577
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
578
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
579
                    Text("New Measurement")
Bogdan Timofte authored a month ago
580
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
581
                    Spacer()
582
                    Image(systemName: "chevron.right")
583
                        .font(.caption.weight(.semibold))
584
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
585
                }
Bogdan Timofte authored a month ago
586
                .padding(.vertical, 10)
587
                .padding(.horizontal, 14)
588
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
589
            }
Bogdan Timofte authored a month ago
590
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
591
        }
Bogdan Timofte authored a month ago
592
        .padding(18)
593
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
594
    }
595

            
Bogdan Timofte authored a month ago
596
    // MARK: - Charging Monitor Card
597

            
Bogdan Timofte authored a month ago
598
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
599
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
Bogdan Timofte authored a month ago
600
        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
Bogdan Timofte authored a month ago
601
        let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
Bogdan Timofte authored a month ago
602
        let metricRows = sessionMetricRows(for: openChargeSession, displayedEnergyWh: displayedEnergyWh)
603

            
Bogdan Timofte authored a month ago
604
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
605
            // Header
606
            HStack {
607
                if let device = selectedChargedDevice {
608
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
609
                        .font(.headline)
610
                } else {
611
                    Text("Charging Monitor").font(.headline)
612
                }
613
                Spacer()
614
                Text(openChargeSession.status.title)
615
                    .font(.caption.weight(.bold))
616
                    .foregroundColor(headerStatusColor)
617
                    .padding(.horizontal, 8)
618
                    .padding(.vertical, 4)
619
                    .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
Bogdan Timofte authored a month ago
620
            }
Bogdan Timofte authored a month ago
621

            
622
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
623
                labels: metricRows.map(\.label),
624
                values: metricRows.map(\.value)
Bogdan Timofte authored a month ago
625
            )
626

            
Bogdan Timofte authored a month ago
627
            if openChargeSession.stopThresholdAmps > 0 {
Bogdan Timofte authored a month ago
628
                Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
Bogdan Timofte authored a month ago
629
                    .font(.caption)
630
                    .foregroundColor(.secondary)
631
            }
632

            
633
            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
634
                for: openChargeSession,
635
                effectiveEnergyWhOverride: displayedEnergyWh
636
            ) {
Bogdan Timofte authored a month ago
637
                HStack(spacing: 6) {
638
                    Image(systemName: "battery.75percent")
639
                        .foregroundColor(.green)
640
                    Text("Predicted: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
Bogdan Timofte authored a month ago
641
                        .font(.caption.weight(.semibold))
Bogdan Timofte authored a month ago
642
                    Text("· \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh est.")
643
                        .font(.caption2)
644
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
645
                }
646
            }
647

            
Bogdan Timofte authored a month ago
648
            if let sessionWarning = sessionWarning(for: openChargeSession) {
Bogdan Timofte authored a month ago
649
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
Bogdan Timofte authored a month ago
650
                    .font(.caption)
651
                    .foregroundColor(.orange)
652
            }
653

            
654
            if openChargeSession.isPaused {
Bogdan Timofte authored a month ago
655
                Label(
656
                    "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
657
                    systemImage: "pause.circle"
658
                )
659
                .font(.caption)
660
                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
661
            }
662

            
Bogdan Timofte authored a month ago
663
            if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
Bogdan Timofte authored a month ago
664
                completionConfirmationCard(openChargeSession)
665
            }
666

            
Bogdan Timofte authored a month ago
667
            BatteryCheckpointSectionView(
668
                sessionID: openChargeSession.id,
669
                checkpoints: openChargeSession.checkpoints,
Bogdan Timofte authored a month ago
670
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
Bogdan Timofte authored a month ago
671
                canAddCheckpoint: canAddCheckpoint,
672
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
673
                effectiveEnergyWhOverride: displayedEnergyWh,
674
                measuredChargeAhOverride: displayedChargeAh,
675
                onDelete: { checkpoint in
676
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
677
                }
Bogdan Timofte authored a month ago
678
            )
Bogdan Timofte authored a month ago
679

            
Bogdan Timofte authored a month ago
680
            targetSectionView(
681
                for: openChargeSession,
682
                predictedPercent: selectedChargedDevice?.batteryLevelPrediction(
683
                    for: openChargeSession,
684
                    effectiveEnergyWhOverride: displayedEnergyWh
685
                )?.predictedPercent
686
            )
687

            
688
            if showingStopConfirm {
689
                stopConfirmPanel(for: openChargeSession)
690
            } else {
691
                // Session controls
692
                HStack(spacing: 10) {
693
                    if openChargeSession.status == .active {
694
                        Button("Pause") {
695
                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
696
                        }
697
                        .frame(maxWidth: .infinity)
698
                        .padding(.vertical, 10)
699
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
700
                        .buttonStyle(.plain)
701
                    } else if openChargeSession.status == .paused {
702
                        Button("Resume") {
703
                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
704
                        }
705
                        .frame(maxWidth: .infinity)
706
                        .padding(.vertical, 10)
707
                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
708
                        .buttonStyle(.plain)
709
                    }
Bogdan Timofte authored a month ago
710

            
Bogdan Timofte authored a month ago
711
                    Button("Stop") {
712
                        finalCheckpointMode = .full
713
                        finalCheckpointText = ""
714
                        showingStopConfirm = true
715
                    }
716
                    .frame(maxWidth: .infinity)
717
                    .padding(.vertical, 10)
718
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
719
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
720
                }
Bogdan Timofte authored a month ago
721
            }
722
        }
723
        .padding(18)
724
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
725
    }
726

            
727
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
728
        VStack(alignment: .leading, spacing: 10) {
729
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
730
                .font(.subheadline.weight(.semibold))
731

            
732
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
733
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
734
                    .font(.caption)
735
                    .foregroundColor(.secondary)
736
            } else {
737
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
738
                    .font(.caption)
739
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
740
            }
741

            
Bogdan Timofte authored a month ago
742
            HStack(spacing: 10) {
743
                Button("Finish") {
744
                    finalCheckpointMode = .full
745
                    finalCheckpointText = ""
746
                    showingStopConfirm = true
Bogdan Timofte authored a month ago
747
                }
748
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
749
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
750
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
751
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
752

            
753
                Button("Keep Monitoring") {
754
                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
755
                }
756
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
757
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
758
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
759
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
760
            }
761
        }
Bogdan Timofte authored a month ago
762
        .padding(14)
763
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
764
    }
765

            
Bogdan Timofte authored a month ago
766
    // MARK: - Target Section
767

            
768
    private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
769
        let draftBelowPrediction: Bool = {
770
            guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
771
            return draft <= predicted
772
        }()
773
        let savedBelowPrediction: Bool = {
774
            guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
775
            return saved <= predicted
776
        }()
777

            
778
        return HStack(alignment: .center, spacing: 8) {
779
            Image(systemName: "bell.badge")
780
                .foregroundColor(.indigo)
781
                .font(.subheadline)
782

            
783
            Text("Notify at")
Bogdan Timofte authored a month ago
784
                .font(.subheadline.weight(.semibold))
785

            
Bogdan Timofte authored a month ago
786
            Spacer(minLength: 8)
787

            
788
            if showingInlineTargetEditor {
789
                Button {
790
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
791
                    let next = max(current - 1, 1)
792
                    draftTargetText = next.format(decimalDigits: 0)
793
                } label: {
794
                    Image(systemName: "minus.circle")
795
                        .font(.title3)
796
                }
797
                .buttonStyle(.plain)
798

            
799
                TextField("—", text: $draftTargetText)
800
                    .keyboardType(.decimalPad)
801
                    .textFieldStyle(.roundedBorder)
802
                    .frame(width: 48)
803
                    .multilineTextAlignment(.center)
804
                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
805

            
806
                Text("%")
807
                    .font(.subheadline)
Bogdan Timofte authored a month ago
808
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
809

            
810
                if draftBelowPrediction {
811
                    Button {} label: {
812
                        Image(systemName: "exclamationmark.triangle.fill")
813
                            .font(.body.weight(.semibold))
814
                            .foregroundColor(.orange)
815
                    }
816
                    .buttonStyle(.plain)
817
                    .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.")
818
                }
819

            
820
                Button {
821
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
822
                    let next = min(current + 1, 100)
823
                    draftTargetText = next.format(decimalDigits: 0)
824
                } label: {
825
                    Image(systemName: "plus.circle")
826
                        .font(.title3)
827
                }
828
                .buttonStyle(.plain)
829

            
830
                Button {
831
                    if let value = parsedDraftTarget {
832
                        _ = appData.setTargetBatteryPercent(value, for: session.id)
833
                    }
834
                    showingInlineTargetEditor = false
835
                } label: {
836
                    Image(systemName: "checkmark.circle.fill")
837
                        .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
838
                        .font(.title3)
839
                }
840
                .buttonStyle(.plain)
841
                .disabled(parsedDraftTarget == nil)
842

            
843
                Button {
844
                    showingInlineTargetEditor = false
845
                    draftTargetText = ""
846
                } label: {
847
                    Image(systemName: "xmark.circle")
848
                        .foregroundColor(.secondary)
849
                        .font(.title3)
850
                }
851
                .buttonStyle(.plain)
852

            
Bogdan Timofte authored a month ago
853
            } else {
Bogdan Timofte authored a month ago
854
                if let targetPercent = session.targetBatteryPercent {
855
                    Text("\(targetPercent.format(decimalDigits: 0))%")
856
                        .font(.subheadline.weight(.semibold))
857
                        .foregroundColor(savedBelowPrediction ? .orange : .indigo)
858

            
859
                    if savedBelowPrediction {
860
                        Button {} label: {
861
                            Image(systemName: "exclamationmark.triangle.fill")
862
                                .font(.callout.weight(.semibold))
863
                                .foregroundColor(.orange)
864
                        }
865
                        .buttonStyle(.plain)
866
                        .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.")
867
                    }
868

            
869
                    Button {
870
                        _ = appData.setTargetBatteryPercent(nil, for: session.id)
871
                    } label: {
872
                        Image(systemName: "xmark.circle.fill")
873
                            .foregroundColor(.secondary)
874
                            .font(.callout)
875
                    }
876
                    .buttonStyle(.plain)
877
                    .help("Remove alert")
878
                }
879

            
880
                Button {
881
                    draftTargetText = session.targetBatteryPercent.map {
882
                        $0.format(decimalDigits: 0)
883
                    } ?? "80"
884
                    showingInlineTargetEditor = true
885
                } label: {
886
                    Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
887
                        .font(.caption.weight(.semibold))
888
                        .frame(width: 30, height: 30)
889
                        .contentShape(Rectangle())
890
                }
891
                .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
892
                .buttonStyle(.plain)
893
                .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
Bogdan Timofte authored a month ago
894
            }
Bogdan Timofte authored a month ago
895
        }
896
    }
Bogdan Timofte authored a month ago
897

            
Bogdan Timofte authored a month ago
898
    private var parsedDraftTarget: Double? {
899
        let normalized = draftTargetText
900
            .trimmingCharacters(in: .whitespacesAndNewlines)
901
            .replacingOccurrences(of: ",", with: ".")
902
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
903
        return value
904
    }
905

            
906
    private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
907
        VStack(alignment: .leading, spacing: 12) {
908
            Text("Final Checkpoint (optional)")
909
                .font(.subheadline.weight(.semibold))
910

            
911
            // Three compact option tiles
912
            HStack(spacing: 8) {
913
                ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
914
                    Button {
915
                        finalCheckpointMode = mode
916
                        if mode != .custom {
917
                            finalCheckpointText = ""
918
                        }
919
                    } label: {
920
                        VStack(spacing: 5) {
921
                            Image(systemName: mode.icon)
922
                                .font(.title3)
923
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
924
                            Text(mode.label)
925
                                .font(.caption.weight(.semibold))
926
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
927
                        }
928
                        .frame(maxWidth: .infinity)
929
                        .padding(.vertical, 10)
930
                        .background(
931
                            finalCheckpointMode == mode
932
                                ? Color.primary.opacity(0.10)
933
                                : Color.clear
934
                        )
935
                        .meterCard(
936
                            tint: finalCheckpointMode == mode ? .primary : .secondary,
937
                            fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
938
                            strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
939
                            cornerRadius: 12
940
                        )
941
                    }
942
                    .buttonStyle(.plain)
943
                }
Bogdan Timofte authored a month ago
944
            }
945

            
Bogdan Timofte authored a month ago
946
            // Custom % input
947
            if finalCheckpointMode == .custom {
948
                HStack(spacing: 8) {
949
                    Button { adjustFinalCheckpoint(by: -1) } label: {
950
                        Image(systemName: "minus.circle").font(.title3)
951
                    }
952
                    .buttonStyle(.plain)
953

            
954
                    TextField("—", text: $finalCheckpointText)
955
                        .keyboardType(.decimalPad)
956
                        .textFieldStyle(.roundedBorder)
957
                        .frame(width: 56)
958
                        .multilineTextAlignment(.center)
959

            
960
                    Text("%")
961
                        .foregroundColor(.secondary)
962

            
963
                    Button { adjustFinalCheckpoint(by: 1) } label: {
964
                        Image(systemName: "plus.circle").font(.title3)
965
                    }
966
                    .buttonStyle(.plain)
967

            
968
                    Spacer()
969
                }
970
            }
971

            
972
            // Action row
973
            HStack(spacing: 10) {
974
                Button("Cancel") {
975
                    showingStopConfirm = false
976
                    finalCheckpointText = ""
977
                }
978
                .frame(maxWidth: .infinity)
979
                .padding(.vertical, 9)
980
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
981
                .buttonStyle(.plain)
982

            
983
                let stopDisabled = finalCheckpointMode == .custom
984
                    && finalCheckpointText.isEmpty == false
985
                    && parsedFinalCheckpoint == nil
986

            
987
                Button("Stop Session") {
988
                    _ = appData.stopChargeSession(
989
                        sessionID: session.id,
990
                        finalBatteryPercent: resolvedFinalCheckpoint
991
                    )
992
                    showingStopConfirm = false
993
                    finalCheckpointText = ""
994
                    finalCheckpointMode = .full
995
                }
996
                .frame(maxWidth: .infinity)
997
                .padding(.vertical, 9)
998
                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
999
                .buttonStyle(.plain)
1000
                .disabled(stopDisabled)
Bogdan Timofte authored a month ago
1001
            }
1002
        }
1003
        .padding(14)
Bogdan Timofte authored a month ago
1004
        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
Bogdan Timofte authored a month ago
1005
    }
1006

            
Bogdan Timofte authored a month ago
1007
    private var parsedFinalCheckpoint: Double? {
1008
        let normalized = finalCheckpointText
1009
            .trimmingCharacters(in: .whitespacesAndNewlines)
1010
            .replacingOccurrences(of: ",", with: ".")
1011
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1012
        return value
1013
    }
1014

            
1015
    private var resolvedFinalCheckpoint: Double? {
1016
        switch finalCheckpointMode {
1017
        case .full:   return 100.0
1018
        case .skip:   return nil
1019
        case .custom: return parsedFinalCheckpoint
1020
        }
1021
    }
1022

            
1023
    private func adjustFinalCheckpoint(by delta: Double) {
1024
        let current = parsedFinalCheckpoint ?? 0
1025
        let next = min(max(current + delta, 0), 100)
1026
        finalCheckpointText = next.format(decimalDigits: 0)
1027
    }
1028

            
1029
    private func sessionChartCard(timeRange: ClosedRange<Date>, session: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
1030
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1031
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1032
                Image(systemName: "chart.xyaxis.line")
1033
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1034
                Text("Session Chart")
1035
                    .font(.headline)
1036
                ContextInfoButton(
1037
                    title: "Session Chart",
1038
                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1039
                )
1040
            }
Bogdan Timofte authored a month ago
1041

            
Bogdan Timofte authored a month ago
1042
            GeometryReader { geometry in
1043
                let chartWidth = max(geometry.size.width, 1)
1044
                let compactChartLayout = chartWidth < 760
1045
                let chartHeight = compactChartLayout ? 290.0 : 350.0
1046

            
1047
                MeasurementChartView(
1048
                    compactLayout: compactChartLayout,
1049
                    availableSize: CGSize(width: chartWidth, height: chartHeight),
1050
                    timeRange: timeRange,
1051
                    showsRangeSelector: false,
1052
                    rebasesEnergyToVisibleRangeStart: true
1053
                )
Bogdan Timofte authored a month ago
1054
                .environmentObject(usbMeter.measurements)
Bogdan Timofte authored a month ago
1055
                .frame(maxWidth: .infinity, alignment: .topLeading)
1056
            }
1057
            .frame(height: 350)
Bogdan Timofte authored a month ago
1058
        }
1059
        .padding(18)
1060
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1061
    }
1062

            
1063
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1064
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1065
            HStack(spacing: 8) {
1066
                Text("Meter Recorder")
1067
                    .font(.headline)
1068

            
1069
                Spacer(minLength: 0)
1070

            
1071
                Button {
1072
                    showsMeterTotalsInfo.toggle()
1073
                } label: {
1074
                    Image(systemName: "info.circle")
1075
                        .font(.body.weight(.semibold))
1076
                        .foregroundColor(.secondary)
1077
                }
1078
                .buttonStyle(.plain)
1079
                .accessibilityLabel("Meter recorder info")
1080
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
1081
                    VStack(alignment: .leading, spacing: 10) {
1082
                        Text("Meter Recorder")
1083
                            .font(.headline)
1084
                        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.")
1085
                            .font(.body)
1086
                            .fixedSize(horizontal: false, vertical: true)
1087
                    }
1088
                    .padding(16)
1089
                    .frame(width: 280, alignment: .leading)
1090
                }
1091
            }
Bogdan Timofte authored a month ago
1092

            
1093
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
1094
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1095
                values: [
1096
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1097
                    usbMeter.recordingDurationDescription,
1098
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1099
                ]
1100
            )
Bogdan Timofte authored a month ago
1101

            
1102
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1103
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1104
                    .font(.caption)
1105
                    .foregroundColor(.secondary)
1106
            }
Bogdan Timofte authored a month ago
1107
        }
1108
        .padding(18)
1109
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1110
    }
1111

            
Bogdan Timofte authored a month ago
1112
    // MARK: - Helpers
1113

            
1114
    private func setupRow<Content: View>(
1115
        icon: String,
1116
        iconColor: Color = .secondary,
1117
        @ViewBuilder content: () -> Content
1118
    ) -> some View {
1119
        HStack(spacing: 10) {
1120
            Image(systemName: icon)
1121
                .foregroundColor(iconColor)
1122
                .font(.body.weight(.medium))
1123
                .frame(width: 22, alignment: .center)
1124
            content()
1125
        }
1126
        .padding(.horizontal, 14)
1127
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1128
    }
1129

            
1130
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1131
        if session.autoStopEnabled == false {
1132
            return "Manual"
1133
        }
1134
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1135
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1136
        }
1137
        if session.stopThresholdAmps > 0 {
1138
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1139
        }
1140
        return "Learning"
1141
    }
1142

            
Bogdan Timofte authored a month ago
1143
    private func sessionMetricRows(
1144
        for session: ChargeSessionSummary,
1145
        displayedEnergyWh: Double
1146
    ) -> [SessionMetricRow] {
1147
        var rows: [SessionMetricRow] = []
1148

            
1149
        if shouldShowChargingTransport(for: session) {
1150
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1151
        }
1152

            
1153
        if shouldShowChargingState(for: session) {
1154
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1155
        }
1156

            
1157
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
1158
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0))))
1159
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1160
        return rows
1161
    }
1162

            
1163
    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1164
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1165
        return selectedChargedDevice.supportedChargingModes.count > 1
1166
            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1167
    }
1168

            
1169
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1170
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1171
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1172
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1173
    }
1174

            
Bogdan Timofte authored a month ago
1175
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1176
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1177
        guard session.status.isOpen else { return storedEnergyWh }
1178
        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1179
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1180
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1181
        }
1182
        return storedEnergyWh
1183
    }
1184

            
Bogdan Timofte authored a month ago
1185
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1186
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1187
        guard session.status.isOpen else { return storedChargeAh }
1188
        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
Bogdan Timofte authored a month ago
1189
        if let baselineChargeAh = session.meterChargeBaselineAh {
1190
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1191
        }
1192
        return storedChargeAh
1193
    }
1194

            
Bogdan Timofte authored a month ago
1195
    private func formatDuration(_ duration: TimeInterval) -> String {
1196
        let totalSeconds = Int(duration.rounded(.down))
1197
        let hours = totalSeconds / 3600
1198
        let minutes = (totalSeconds % 3600) / 60
1199
        let seconds = totalSeconds % 60
1200
        if hours > 0 {
1201
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1202
        }
1203
        return String(format: "%02d:%02d", minutes, seconds)
1204
    }
1205

            
Bogdan Timofte authored a month ago
1206
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1207
        guard session.chargingTransportMode == .wireless,
1208
              let chargerID = session.chargerID,
1209
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1210
            return nil
1211
        }
Bogdan Timofte authored a month ago
1212
        guard charger.chargerIdleCurrentAmps == nil else { return nil }
Bogdan Timofte authored a month ago
1213
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1214
    }
1215

            
1216
    private func startSession() {
1217
        guard let selectedChargedDevice,
1218
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1219
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1220
            return
1221
        }
1222

            
1223
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1224
        let didStart = appData.startChargeSession(
1225
            for: usbMeter,
1226
            chargedDeviceID: selectedChargedDevice.id,
1227
            chargerID: chargerID,
1228
            chargingTransportMode: chargingTransportMode,
1229
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1230
            autoStopEnabled: false,
1231
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1232
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1233
        )
Bogdan Timofte authored a month ago
1234

            
1235
        if didStart {
1236
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1237
            initialCheckpointMode = .known
1238
        }
1239
    }
1240

            
1241
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1242
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1243
        let currentValue = initialCheckpointValue ?? 0
1244
        let nextValue = min(max(currentValue + delta, 0), 100)
1245
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1246
    }
1247

            
Bogdan Timofte authored a month ago
1248
    private func syncDraftSelections() {
1249
        guard let selectedChargedDevice else {
1250
            draftChargingTransportMode = nil
1251
            draftChargingStateMode = nil
1252
            return
1253
        }
1254

            
1255
        if let openChargeSession {
1256
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1257
            draftChargingStateMode = openChargeSession.chargingStateMode
1258
            return
1259
        }
1260

            
1261
        if let draftChargingTransportMode,
1262
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1263
            self.draftChargingTransportMode = nil
1264
        }
1265

            
1266
        if let draftChargingStateMode,
1267
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1268
            self.draftChargingStateMode = nil
1269
        }
1270

            
1271
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1272
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1273
        }
1274

            
Bogdan Timofte authored a month ago
1275
        if let draftChargingTransportMode {
1276
            draftChargingStateMode = draftChargingStateMode
1277
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1278
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1279
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1280
        }
Bogdan Timofte authored a month ago
1281
    }
Bogdan Timofte authored a month ago
1282

            
1283
    private struct CompactSelectionOption: Identifiable {
1284
        let id: String
1285
        let title: String
1286
        let isSelected: Bool
1287
        let action: () -> Void
1288
    }
1289

            
1290
    private func compactSelectionMenu(
1291
        title: String,
1292
        options: [CompactSelectionOption]
1293
    ) -> some View {
1294
        Menu {
1295
            ForEach(options) { option in
1296
                Button {
1297
                    option.action()
1298
                } label: {
1299
                    if option.isSelected {
1300
                        Label(option.title, systemImage: "checkmark")
1301
                    } else {
1302
                        Text(option.title)
1303
                    }
1304
                }
1305
            }
1306
        } label: {
1307
            HStack(spacing: 8) {
1308
                Text(title)
1309
                    .foregroundColor(.primary)
1310
                Spacer()
1311
                Image(systemName: "chevron.up.chevron.down")
1312
                    .font(.caption.weight(.semibold))
1313
                    .foregroundColor(.secondary)
1314
            }
1315
            .padding(.horizontal, 12)
1316
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1317
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1318
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1319
        }
1320
        .buttonStyle(.plain)
1321
    }
Bogdan Timofte authored a month ago
1322
}
1323