USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
741 lines | 28.353kb
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
                                if charger.chargerIdleCurrentAmps == nil {
406
                                    Image(systemName: "exclamationmark.triangle.fill")
407
                                        .foregroundColor(.orange)
408
                                        .font(.caption)
409
                                }
410
                            } else {
411
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
412
                                    .foregroundColor(.secondary)
413
                                    .font(.subheadline)
414
                            }
415
                            Spacer(minLength: 8)
416
                            Image(systemName: "chevron.up.chevron.down")
417
                                .font(.caption.weight(.semibold))
418
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
419
                        }
Bogdan Timofte authored a month ago
420
                    }
Bogdan Timofte authored a month ago
421
                    .pickerStyle(.menu)
422
                    .disabled(availableChargers.isEmpty)
Bogdan Timofte authored a month ago
423
                }
Bogdan Timofte authored a month ago
424
            }
425

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

            
435
                    TextField("—", text: $initialCheckpoint)
436
                        .keyboardType(.decimalPad)
437
                        .textFieldStyle(.roundedBorder)
438
                        .frame(width: 52)
439
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
440

            
Bogdan Timofte authored a month ago
441
                    Text("%")
442
                        .font(.subheadline)
443
                        .foregroundColor(.secondary)
444

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

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

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

            
Bogdan Timofte authored a month ago
498
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
499

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

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

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

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

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

            
Bogdan Timofte authored a month ago
568
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
569
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
570
            HStack(spacing: 8) {
571
                Text("Meter Recorder")
572
                    .font(.headline)
573

            
574
                Spacer(minLength: 0)
575

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

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

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

            
Bogdan Timofte authored a month ago
617
    // MARK: - Helpers
618

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

            
635
    private func startSession() {
636
        guard let selectedChargedDevice,
637
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
638
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
639
            return
640
        }
641

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

            
654
        if didStart {
655
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
656
            initialCheckpointMode = .known
657
        }
658
    }
659

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

            
Bogdan Timofte authored a month ago
667
    private func syncDraftSelections() {
668
        guard let selectedChargedDevice else {
669
            draftChargingTransportMode = nil
670
            draftChargingStateMode = nil
671
            return
672
        }
673

            
674
        if let openChargeSession {
675
            draftChargingTransportMode = openChargeSession.chargingTransportMode
676
            draftChargingStateMode = openChargeSession.chargingStateMode
677
            return
678
        }
679

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

            
685
        if let draftChargingStateMode,
686
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
687
            self.draftChargingStateMode = nil
688
        }
689

            
690
        if selectedChargedDevice.supportedChargingModes.count == 1 {
691
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
692
        }
693

            
Bogdan Timofte authored a month ago
694
        if let draftChargingTransportMode {
695
            draftChargingStateMode = draftChargingStateMode
696
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
697
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
698
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
699
        }
Bogdan Timofte authored a month ago
700
    }
Bogdan Timofte authored a month ago
701

            
702
    private struct CompactSelectionOption: Identifiable {
703
        let id: String
704
        let title: String
705
        let isSelected: Bool
706
        let action: () -> Void
707
    }
708

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