USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
736 lines | 28.035kb
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 enum InitialCheckpointMode: String, CaseIterable, Identifiable {
20
        case known
21
        case unknown
22
        case flat
23

            
24
        var id: String { rawValue }
25

            
26
        var title: String {
27
            switch self {
Bogdan Timofte authored a month ago
28
            case .known:   return "Known"
29
            case .unknown: return "Unknown"
30
            case .flat:    return "Flat"
Bogdan Timofte authored a month ago
31
            }
32
        }
33
    }
34

            
Bogdan Timofte authored a month ago
35
    private enum ActiveMode: Hashable {
36
        case chargeSession
37
        case standbyPower
38
    }
Bogdan Timofte authored a month ago
39

            
40
    private enum SessionStartRequirement: Identifiable {
41
        case existingSession
42
        case device
43
        case chargingType
44
        case chargingMode
45
        case charger
46
        case initialCheckpointEmpty
47
        case initialCheckpointInvalid
48

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

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

            
Bogdan Timofte authored a month ago
74
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
75
    @EnvironmentObject private var usbMeter: Meter
76

            
77
    @State private var draftChargingTransportMode: ChargingTransportMode?
78
    @State private var draftChargingStateMode: ChargingStateMode?
79
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
80
    @State private var initialCheckpoint = ""
81
    @State private var showsMeterTotalsInfo = false
82
    @State private var activeMode: ActiveMode = .chargeSession
83

            
Bogdan Timofte authored a month ago
84
    var body: some View {
Bogdan Timofte authored a month ago
85
        Group {
86
            if let openChargeSession {
87
                ChargeSessionDetailView(
88
                    chargedDeviceID: openChargeSession.chargedDeviceID,
89
                    sessionID: openChargeSession.id,
90
                    monitoringMeter: usbMeter,
91
                    presentation: .embedded
92
                )
93
            } else {
94
                ScrollView {
95
                    VStack(spacing: 14) {
96
                        statusHeader
97
                        liveMeterStripView
98
                        modePicker
99

            
100
                        switch activeMode {
101
                        case .chargeSession:
102
                            chargeSessionSetupCard
103
                        case .standbyPower:
104
                            standbyPowerCard
105
                        }
Bogdan Timofte authored a month ago
106
                    }
Bogdan Timofte authored a month ago
107
                    .padding()
Bogdan Timofte authored a month ago
108
                }
109
            }
110
        }
111
        .background(
112
            LinearGradient(
113
                colors: [.pink.opacity(0.14), Color.clear],
114
                startPoint: .topLeading,
115
                endPoint: .bottomTrailing
116
            )
117
            .ignoresSafeArea()
118
        )
Bogdan Timofte authored a month ago
119
        .onAppear {
120
            syncDraftSelections()
121
        }
122
        .onChange(of: selectedChargedDevice?.id) { _ in
123
            syncDraftSelections()
124
        }
125
        .onChange(of: openChargeSession?.id) { _ in
126
            syncDraftSelections()
127
        }
Bogdan Timofte authored a month ago
128
    }
129

            
Bogdan Timofte authored a month ago
130
    // MARK: - Computed Properties
131

            
Bogdan Timofte authored a month ago
132
    private var meterMACAddress: String {
133
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
134
    }
135

            
Bogdan Timofte authored a month ago
136
    private var selectedChargedDevice: ChargedDeviceSummary? {
137
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
138
    }
139

            
Bogdan Timofte authored a month ago
140
    private var availableChargedDevices: [ChargedDeviceSummary] {
141
        appData.deviceSummaries
142
    }
143

            
144
    private var selectedChargedDeviceID: Binding<UUID?> {
145
        Binding(
146
            get: { selectedChargedDevice?.id },
147
            set: { newValue in
148
                guard let newValue else { return }
149
                _ = appData.assignChargedDevice(newValue, to: meterMACAddress)
150
            }
151
        )
152
    }
153

            
Bogdan Timofte authored a month ago
154
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
155
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
156
    }
157

            
Bogdan Timofte authored a month ago
158
    private var availableChargers: [ChargedDeviceSummary] {
159
        appData.chargerSummaries
160
    }
161

            
162
    private var selectedChargerID: Binding<UUID?> {
163
        Binding(
164
            get: { selectedCharger?.id },
165
            set: { newValue in
166
                guard let newValue else { return }
167
                _ = appData.assignCharger(newValue, to: meterMACAddress)
168
            }
169
        )
170
    }
171

            
Bogdan Timofte authored a month ago
172
    private var openChargeSession: ChargeSessionSummary? {
173
        appData.activeChargeSessionSummary(for: meterMACAddress)
174
    }
175

            
Bogdan Timofte authored a month ago
176
    private var showsMeterTotalsCard: Bool {
177
        usbMeter.supportsRecordingView
178
            || usbMeter.supportsDataGroupCommands
179
            || usbMeter.recordedAH > 0
180
            || usbMeter.recordedWH > 0
181
            || usbMeter.recordingDuration > 0
182
    }
183

            
Bogdan Timofte authored a month ago
184
    private var selectedDraftTransportMode: ChargingTransportMode? {
185
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
186
    }
187

            
188
    private var selectedDraftChargingStateMode: ChargingStateMode? {
189
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
190
    }
191

            
Bogdan Timofte authored a month ago
192
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
193
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
194
        let normalized = initialCheckpoint
195
            .trimmingCharacters(in: .whitespacesAndNewlines)
196
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
197
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
198
        return value
199
    }
200

            
Bogdan Timofte authored a month ago
201
    private var hasInitialCheckpointInput: Bool {
202
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
203
    }
204

            
205
    private var shouldRequireInitialCheckpoint: Bool {
206
        initialCheckpointMode == .known
207
    }
208

            
Bogdan Timofte authored a month ago
209
    private var requiresExplicitTransportSelection: Bool {
210
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
211
    }
212

            
213
    private var requiresExplicitChargingStateSelection: Bool {
214
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
215
    }
216

            
Bogdan Timofte authored a month ago
217
    private var startRequirements: [SessionStartRequirement] {
218
        var requirements: [SessionStartRequirement] = []
219

            
220
        if openChargeSession != nil {
221
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
222
        }
223

            
Bogdan Timofte authored a month ago
224
        guard let selectedChargedDevice else {
225
            requirements.append(.device)
226
            return requirements
Bogdan Timofte authored a month ago
227
        }
228

            
Bogdan Timofte authored a month ago
229
        guard let chargingTransportMode = selectedDraftTransportMode else {
230
            requirements.append(.chargingType)
231
            return requirements
Bogdan Timofte authored a month ago
232
        }
233

            
Bogdan Timofte authored a month ago
234
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
235
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
236
        }
237

            
Bogdan Timofte authored a month ago
238
        guard let chargingStateMode = selectedDraftChargingStateMode else {
239
            requirements.append(.chargingMode)
240
            return requirements
241
        }
242

            
243
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
244
            requirements.append(.chargingMode)
245
        }
246

            
247
        if chargingTransportMode == .wireless, selectedCharger == nil {
248
            requirements.append(.charger)
249
        }
250

            
251
        if shouldRequireInitialCheckpoint {
252
            if hasInitialCheckpointInput == false {
253
                requirements.append(.initialCheckpointEmpty)
254
            } else if initialCheckpointValue == nil {
255
                requirements.append(.initialCheckpointInvalid)
256
            }
257
        }
258

            
259
        return requirements
260
    }
261

            
262
    private var canStartSession: Bool {
263
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
264
    }
265

            
266
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
267
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
268
        return openChargeSession.status.title
269
    }
270

            
271
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
272
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
273
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
274
        case .active:    return .red
275
        case .paused:    return .orange
276
        case .completed: return .green
277
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
278
        }
279
    }
280

            
Bogdan Timofte authored a month ago
281
    private var showsWirelessChargerSection: Bool {
282
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
283
        return transportMode == .wireless
284
    }
Bogdan Timofte authored a month ago
285

            
Bogdan Timofte authored a month ago
286
    // MARK: - Status Header
287

            
288
    private var statusHeader: some View {
289
        HStack {
290
            Image(systemName: "bolt.fill")
291
                .foregroundColor(.pink)
292
            Text("Charging Session")
293
                .font(.system(.title3, design: .rounded).weight(.bold))
294
            Spacer()
295
            Text(headerStatusTitle)
296
                .font(.caption.weight(.bold))
297
                .foregroundColor(headerStatusColor)
298
                .padding(.horizontal, 10)
299
                .padding(.vertical, 6)
300
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
301
        }
302
        .padding(.horizontal, 18)
303
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
304
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
305
    }
306

            
Bogdan Timofte authored a month ago
307
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
308

            
Bogdan Timofte authored a month ago
309
    private var modePicker: some View {
310
        Picker("", selection: $activeMode) {
311
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
312
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
313
        }
Bogdan Timofte authored a month ago
314
        .pickerStyle(.segmented)
315
        .labelsHidden()
Bogdan Timofte authored a month ago
316
    }
317

            
Bogdan Timofte authored a month ago
318
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
319

            
Bogdan Timofte authored a month ago
320
    private var chargeSessionSetupCard: some View {
321
        VStack(alignment: .leading, spacing: 0) {
322
            // Device
323
            setupRow(icon: "iphone", iconColor: .blue) {
Bogdan Timofte authored a month ago
324
                Picker(selection: selectedChargedDeviceID) {
325
                    Text("Choose device").tag(UUID?.none)
326
                    ForEach(availableChargedDevices) { device in
327
                        Text(device.name).tag(Optional(device.id))
328
                    }
329
                } label: {
330
                    HStack(spacing: 8) {
331
                        if let device = selectedChargedDevice {
332
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
333
                                .font(.subheadline.weight(.semibold))
334
                        } else {
335
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
336
                                .foregroundColor(.secondary)
337
                                .font(.subheadline)
338
                        }
339
                        Spacer(minLength: 8)
340
                        Image(systemName: "chevron.up.chevron.down")
341
                            .font(.caption.weight(.semibold))
342
                            .foregroundColor(.secondary)
343
                    }
Bogdan Timofte authored a month ago
344
                }
Bogdan Timofte authored a month ago
345
                .pickerStyle(.menu)
346
                .disabled(availableChargedDevices.isEmpty)
Bogdan Timofte authored a month ago
347
            }
348

            
Bogdan Timofte authored a month ago
349
            // Charging type — only when device supports multiple
350
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
351
                Divider().padding(.leading, 46)
352
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
353
                    Text("Type")
354
                        .foregroundColor(.secondary)
355
                        .font(.subheadline)
356
                    Spacer()
Bogdan Timofte authored a month ago
357
                    compactSelectionMenu(
358
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
359
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
360
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
361
                                id: mode.id, title: mode.title,
362
                                isSelected: draftChargingTransportMode == mode,
363
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
364
                            )
Bogdan Timofte authored a month ago
365
                        }
Bogdan Timofte authored a month ago
366
                    )
Bogdan Timofte authored a month ago
367
                }
Bogdan Timofte authored a month ago
368
            }
Bogdan Timofte authored a month ago
369

            
Bogdan Timofte authored a month ago
370
            // Charging state — only when device supports multiple
371
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
372
                Divider().padding(.leading, 46)
373
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
374
                    Text("Mode")
375
                        .foregroundColor(.secondary)
376
                        .font(.subheadline)
377
                    Spacer()
Bogdan Timofte authored a month ago
378
                    compactSelectionMenu(
379
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
380
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
381
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
382
                                id: mode.id, title: mode.title,
383
                                isSelected: draftChargingStateMode == mode,
384
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
385
                            )
Bogdan Timofte authored a month ago
386
                        }
Bogdan Timofte authored a month ago
387
                    )
Bogdan Timofte authored a month ago
388
                }
389
            }
Bogdan Timofte authored a month ago
390

            
Bogdan Timofte authored a month ago
391
            // Wireless charger — only when wireless transport
392
            if showsWirelessChargerSection {
393
                Divider().padding(.leading, 46)
394
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
395
                    Picker(selection: selectedChargerID) {
396
                        Text("Choose charger").tag(UUID?.none)
397
                        ForEach(availableChargers) { charger in
398
                            Text(charger.name).tag(Optional(charger.id))
399
                        }
400
                    } label: {
401
                        HStack(spacing: 8) {
402
                            if let charger = selectedCharger {
403
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
404
                                    .font(.subheadline.weight(.semibold))
405
                            } else {
406
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
407
                                    .foregroundColor(.secondary)
408
                                    .font(.subheadline)
409
                            }
410
                            Spacer(minLength: 8)
411
                            Image(systemName: "chevron.up.chevron.down")
412
                                .font(.caption.weight(.semibold))
413
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
414
                        }
Bogdan Timofte authored a month ago
415
                    }
Bogdan Timofte authored a month ago
416
                    .pickerStyle(.menu)
417
                    .disabled(availableChargers.isEmpty)
Bogdan Timofte authored a month ago
418
                }
Bogdan Timofte authored a month ago
419
            }
420

            
Bogdan Timofte authored a month ago
421
            // Battery checkpoint
422
            Divider().padding(.leading, 46)
423
            setupRow(icon: "battery.75percent", iconColor: .green) {
424
                if initialCheckpointMode == .known {
425
                    Button { adjustInitialCheckpoint(by: -1) } label: {
426
                        Image(systemName: "minus.circle").font(.title3)
427
                    }
428
                    .buttonStyle(.plain)
429

            
430
                    TextField("—", text: $initialCheckpoint)
431
                        .keyboardType(.decimalPad)
432
                        .textFieldStyle(.roundedBorder)
433
                        .frame(width: 52)
434
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
435

            
Bogdan Timofte authored a month ago
436
                    Text("%")
437
                        .font(.subheadline)
438
                        .foregroundColor(.secondary)
439

            
440
                    Button { adjustInitialCheckpoint(by: 1) } label: {
441
                        Image(systemName: "plus.circle").font(.title3)
442
                    }
443
                    .buttonStyle(.plain)
444
                } else {
445
                    Text(initialCheckpointMode == .flat
446
                         ? "Flat (device off / discharged)"
447
                         : "Unknown")
448
                        .font(.subheadline)
449
                        .foregroundColor(.secondary)
450
                }
451
                Spacer()
Bogdan Timofte authored a month ago
452
                compactSelectionMenu(
453
                    title: initialCheckpointMode.title,
454
                    options: InitialCheckpointMode.allCases.map { mode in
455
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
456
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
457
                            isSelected: initialCheckpointMode == mode,
458
                            action: { initialCheckpointMode = mode }
459
                        )
460
                    }
461
                )
Bogdan Timofte authored a month ago
462
            }
463

            
Bogdan Timofte authored a month ago
464
            // Requirement errors
465
            if startRequirements.isEmpty == false {
466
                Divider()
467
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
468
                    ForEach(startRequirements) { requirement in
469
                        Label(requirement.message, systemImage: "exclamationmark.circle")
470
                            .font(.caption)
471
                            .foregroundColor(.orange)
472
                    }
473
                }
Bogdan Timofte authored a month ago
474
                .padding(.horizontal, 14)
475
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
476
            }
Bogdan Timofte authored a month ago
477

            
Bogdan Timofte authored a month ago
478
            // Start button
479
            Divider()
Bogdan Timofte authored a month ago
480
            Button("Start Session") {
481
                startSession()
482
            }
483
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
484
            .padding(.vertical, 11)
485
            .font(.subheadline.weight(.semibold))
486
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
487
            .buttonStyle(.plain)
488
            .disabled(!canStartSession)
489
        }
Bogdan Timofte authored a month ago
490
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
491
    }
492

            
Bogdan Timofte authored a month ago
493
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
494

            
Bogdan Timofte authored a month ago
495
    private var standbyPowerCard: some View {
496
        VStack(alignment: .leading, spacing: 12) {
497
            HStack(spacing: 10) {
498
                Image(systemName: "powersleep")
499
                    .foregroundColor(.orange)
500
                    .font(.title3)
501
                VStack(alignment: .leading, spacing: 2) {
502
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
503
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
504
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
505
                        .font(.caption)
506
                        .foregroundColor(.secondary)
507
                }
Bogdan Timofte authored a month ago
508
            }
Bogdan Timofte authored a month ago
509

            
Bogdan Timofte authored a month ago
510
            NavigationLink(
511
                destination: ChargerStandbyPowerWizardView(
512
                    preferredMeterMACAddress: meterMACAddress
513
                )
514
            ) {
515
                HStack {
516
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
517
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
518
                    Text("New Measurement")
Bogdan Timofte authored a month ago
519
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
520
                    Spacer()
521
                    Image(systemName: "chevron.right")
522
                        .font(.caption.weight(.semibold))
523
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
524
                }
Bogdan Timofte authored a month ago
525
                .padding(.vertical, 10)
526
                .padding(.horizontal, 14)
527
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
528
            }
Bogdan Timofte authored a month ago
529
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
530
        }
Bogdan Timofte authored a month ago
531
        .padding(18)
532
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
533
    }
534

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

            
537
    private var liveMeterStripView: some View {
538
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
539
        return LazyVGrid(columns: columns, spacing: 8) {
540
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
541
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
542
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
543
        }
544
    }
545

            
546
    private func metricCell(label: String, value: String, tint: Color) -> some View {
547
        VStack(alignment: .leading, spacing: 3) {
548
            Text(label)
549
                .font(.caption2)
550
                .foregroundColor(.secondary)
551
            Text(value)
552
                .font(.subheadline.weight(.semibold))
553
                .lineLimit(1)
554
                .minimumScaleFactor(0.7)
555
                .monospacedDigit()
556
        }
557
        .frame(maxWidth: .infinity, alignment: .leading)
558
        .padding(.horizontal, 12)
559
        .padding(.vertical, 10)
560
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
561
    }
562

            
Bogdan Timofte authored a month ago
563
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
564
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
565
            HStack(spacing: 8) {
566
                Text("Meter Recorder")
567
                    .font(.headline)
568

            
569
                Spacer(minLength: 0)
570

            
571
                Button {
572
                    showsMeterTotalsInfo.toggle()
573
                } label: {
574
                    Image(systemName: "info.circle")
575
                        .font(.body.weight(.semibold))
576
                        .foregroundColor(.secondary)
577
                }
578
                .buttonStyle(.plain)
579
                .accessibilityLabel("Meter recorder info")
580
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
581
                    VStack(alignment: .leading, spacing: 10) {
582
                        Text("Meter Recorder")
583
                            .font(.headline)
584
                        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.")
585
                            .font(.body)
586
                            .fixedSize(horizontal: false, vertical: true)
587
                    }
588
                    .padding(16)
589
                    .frame(width: 280, alignment: .leading)
590
                }
591
            }
Bogdan Timofte authored a month ago
592

            
593
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
594
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
595
                values: [
596
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
597
                    usbMeter.recordingDurationDescription,
598
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
599
                ]
600
            )
Bogdan Timofte authored a month ago
601

            
602
            if let recordingBootedAt = usbMeter.recordingBootedAt {
603
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
604
                    .font(.caption)
605
                    .foregroundColor(.secondary)
606
            }
Bogdan Timofte authored a month ago
607
        }
608
        .padding(18)
609
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
610
    }
611

            
Bogdan Timofte authored a month ago
612
    // MARK: - Helpers
613

            
614
    private func setupRow<Content: View>(
615
        icon: String,
616
        iconColor: Color = .secondary,
617
        @ViewBuilder content: () -> Content
618
    ) -> some View {
619
        HStack(spacing: 10) {
620
            Image(systemName: icon)
621
                .foregroundColor(iconColor)
622
                .font(.body.weight(.medium))
623
                .frame(width: 22, alignment: .center)
624
            content()
625
        }
626
        .padding(.horizontal, 14)
627
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
628
    }
629

            
630
    private func startSession() {
631
        guard let selectedChargedDevice,
632
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
633
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
634
            return
635
        }
636

            
637
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
638
        let didStart = appData.startChargeSession(
639
            for: usbMeter,
640
            chargedDeviceID: selectedChargedDevice.id,
641
            chargerID: chargerID,
642
            chargingTransportMode: chargingTransportMode,
643
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
644
            autoStopEnabled: false,
645
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
646
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
647
        )
Bogdan Timofte authored a month ago
648

            
649
        if didStart {
650
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
651
            initialCheckpointMode = .known
652
        }
653
    }
654

            
655
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
656
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
657
        let currentValue = initialCheckpointValue ?? 0
658
        let nextValue = min(max(currentValue + delta, 0), 100)
659
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
660
    }
661

            
Bogdan Timofte authored a month ago
662
    private func syncDraftSelections() {
663
        guard let selectedChargedDevice else {
664
            draftChargingTransportMode = nil
665
            draftChargingStateMode = nil
666
            return
667
        }
668

            
669
        if let openChargeSession {
670
            draftChargingTransportMode = openChargeSession.chargingTransportMode
671
            draftChargingStateMode = openChargeSession.chargingStateMode
672
            return
673
        }
674

            
675
        if let draftChargingTransportMode,
676
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
677
            self.draftChargingTransportMode = nil
678
        }
679

            
680
        if let draftChargingStateMode,
681
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
682
            self.draftChargingStateMode = nil
683
        }
684

            
685
        if selectedChargedDevice.supportedChargingModes.count == 1 {
686
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
687
        }
688

            
Bogdan Timofte authored a month ago
689
        if let draftChargingTransportMode {
690
            draftChargingStateMode = draftChargingStateMode
691
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
692
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
693
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
694
        }
Bogdan Timofte authored a month ago
695
    }
Bogdan Timofte authored a month ago
696

            
697
    private struct CompactSelectionOption: Identifiable {
698
        let id: String
699
        let title: String
700
        let isSelected: Bool
701
        let action: () -> Void
702
    }
703

            
704
    private func compactSelectionMenu(
705
        title: String,
706
        options: [CompactSelectionOption]
707
    ) -> some View {
708
        Menu {
709
            ForEach(options) { option in
710
                Button {
711
                    option.action()
712
                } label: {
713
                    if option.isSelected {
714
                        Label(option.title, systemImage: "checkmark")
715
                    } else {
716
                        Text(option.title)
717
                    }
718
                }
719
            }
720
        } label: {
721
            HStack(spacing: 8) {
722
                Text(title)
723
                    .foregroundColor(.primary)
724
                Spacer()
725
                Image(systemName: "chevron.up.chevron.down")
726
                    .font(.caption.weight(.semibold))
727
                    .foregroundColor(.secondary)
728
            }
729
            .padding(.horizontal, 12)
730
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
731
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
732
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
733
        }
734
        .buttonStyle(.plain)
735
    }
Bogdan Timofte authored a month ago
736
}