USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
753 lines | 28.751kb
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
Bogdan Timofte authored a month ago
83
    @State private var draftChargedDeviceID: UUID?
84
    @State private var draftChargerID: UUID?
Bogdan Timofte authored a month ago
85

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

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

            
Bogdan Timofte authored a month ago
132
    // MARK: - Computed Properties
133

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

            
Bogdan Timofte authored a month ago
138
    private var selectedChargedDevice: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
139
        if let openChargeSession {
140
            return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID)
141
        }
142

            
143
        guard let draftChargedDeviceID else { return nil }
144
        let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID)
145
        return chargedDevice?.isCharger == false ? chargedDevice : nil
Bogdan Timofte authored a month ago
146
    }
147

            
Bogdan Timofte authored a month ago
148
    private var availableChargedDevices: [ChargedDeviceSummary] {
149
        appData.deviceSummaries
150
    }
151

            
152
    private var selectedChargedDeviceID: Binding<UUID?> {
153
        Binding(
Bogdan Timofte authored a month ago
154
            get: { openChargeSession?.chargedDeviceID ?? draftChargedDeviceID },
Bogdan Timofte authored a month ago
155
            set: { newValue in
Bogdan Timofte authored a month ago
156
                draftChargedDeviceID = newValue
157
                if newValue == nil {
158
                    draftChargingTransportMode = nil
159
                    draftChargingStateMode = nil
160
                }
Bogdan Timofte authored a month ago
161
            }
162
        )
163
    }
164

            
Bogdan Timofte authored a month ago
165
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
166
        if let openChargeSession,
167
           let chargerID = openChargeSession.chargerID {
168
            return appData.chargedDeviceSummary(id: chargerID)
169
        }
170

            
171
        guard let draftChargerID else { return nil }
172
        let charger = appData.chargedDeviceSummary(id: draftChargerID)
173
        return charger?.isCharger == true ? charger : nil
Bogdan Timofte authored a month ago
174
    }
175

            
Bogdan Timofte authored a month ago
176
    private var availableChargers: [ChargedDeviceSummary] {
177
        appData.chargerSummaries
178
    }
179

            
180
    private var selectedChargerID: Binding<UUID?> {
181
        Binding(
Bogdan Timofte authored a month ago
182
            get: { openChargeSession?.chargerID ?? draftChargerID },
Bogdan Timofte authored a month ago
183
            set: { newValue in
Bogdan Timofte authored a month ago
184
                draftChargerID = newValue
Bogdan Timofte authored a month ago
185
            }
186
        )
187
    }
188

            
Bogdan Timofte authored a month ago
189
    private var openChargeSession: ChargeSessionSummary? {
190
        appData.activeChargeSessionSummary(for: meterMACAddress)
191
    }
192

            
Bogdan Timofte authored a month ago
193
    private var showsMeterTotalsCard: Bool {
194
        usbMeter.supportsRecordingView
195
            || usbMeter.supportsDataGroupCommands
196
            || usbMeter.recordedAH > 0
197
            || usbMeter.recordedWH > 0
198
            || usbMeter.recordingDuration > 0
199
    }
200

            
Bogdan Timofte authored a month ago
201
    private var selectedDraftTransportMode: ChargingTransportMode? {
202
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
203
    }
204

            
205
    private var selectedDraftChargingStateMode: ChargingStateMode? {
206
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
207
    }
208

            
Bogdan Timofte authored a month ago
209
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
210
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
211
        let normalized = initialCheckpoint
212
            .trimmingCharacters(in: .whitespacesAndNewlines)
213
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
214
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
215
        return value
216
    }
217

            
Bogdan Timofte authored a month ago
218
    private var hasInitialCheckpointInput: Bool {
219
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
220
    }
221

            
222
    private var shouldRequireInitialCheckpoint: Bool {
223
        initialCheckpointMode == .known
224
    }
225

            
Bogdan Timofte authored a month ago
226
    private var requiresExplicitTransportSelection: Bool {
227
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
228
    }
229

            
230
    private var requiresExplicitChargingStateSelection: Bool {
231
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
232
    }
233

            
Bogdan Timofte authored a month ago
234
    private var startRequirements: [SessionStartRequirement] {
235
        var requirements: [SessionStartRequirement] = []
236

            
237
        if openChargeSession != nil {
238
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
239
        }
240

            
Bogdan Timofte authored a month ago
241
        guard let selectedChargedDevice else {
242
            requirements.append(.device)
243
            return requirements
Bogdan Timofte authored a month ago
244
        }
245

            
Bogdan Timofte authored a month ago
246
        guard let chargingTransportMode = selectedDraftTransportMode else {
247
            requirements.append(.chargingType)
248
            return requirements
Bogdan Timofte authored a month ago
249
        }
250

            
Bogdan Timofte authored a month ago
251
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
252
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
253
        }
254

            
Bogdan Timofte authored a month ago
255
        guard let chargingStateMode = selectedDraftChargingStateMode else {
256
            requirements.append(.chargingMode)
257
            return requirements
258
        }
259

            
260
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
261
            requirements.append(.chargingMode)
262
        }
263

            
264
        if chargingTransportMode == .wireless, selectedCharger == nil {
265
            requirements.append(.charger)
266
        }
267

            
268
        if shouldRequireInitialCheckpoint {
269
            if hasInitialCheckpointInput == false {
270
                requirements.append(.initialCheckpointEmpty)
271
            } else if initialCheckpointValue == nil {
272
                requirements.append(.initialCheckpointInvalid)
273
            }
274
        }
275

            
276
        return requirements
277
    }
278

            
279
    private var canStartSession: Bool {
280
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
281
    }
282

            
283
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
284
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
285
        return openChargeSession.status.title
286
    }
287

            
288
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
289
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
290
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
291
        case .active:    return .red
292
        case .paused:    return .orange
293
        case .completed: return .green
294
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
295
        }
296
    }
297

            
Bogdan Timofte authored a month ago
298
    private var showsWirelessChargerSection: Bool {
299
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
300
        return transportMode == .wireless
301
    }
Bogdan Timofte authored a month ago
302

            
Bogdan Timofte authored a month ago
303
    // MARK: - Status Header
304

            
305
    private var statusHeader: some View {
306
        HStack {
307
            Image(systemName: "bolt.fill")
308
                .foregroundColor(.pink)
309
            Text("Charging Session")
310
                .font(.system(.title3, design: .rounded).weight(.bold))
311
            Spacer()
312
            Text(headerStatusTitle)
313
                .font(.caption.weight(.bold))
314
                .foregroundColor(headerStatusColor)
315
                .padding(.horizontal, 10)
316
                .padding(.vertical, 6)
317
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
318
        }
319
        .padding(.horizontal, 18)
320
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
321
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
322
    }
323

            
Bogdan Timofte authored a month ago
324
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
325

            
Bogdan Timofte authored a month ago
326
    private var modePicker: some View {
327
        Picker("", selection: $activeMode) {
328
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
329
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
330
        }
Bogdan Timofte authored a month ago
331
        .pickerStyle(.segmented)
332
        .labelsHidden()
Bogdan Timofte authored a month ago
333
    }
334

            
Bogdan Timofte authored a month ago
335
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
336

            
Bogdan Timofte authored a month ago
337
    private var chargeSessionSetupCard: some View {
338
        VStack(alignment: .leading, spacing: 0) {
339
            // Device
340
            setupRow(icon: "iphone", iconColor: .blue) {
Bogdan Timofte authored a month ago
341
                Picker(selection: selectedChargedDeviceID) {
342
                    Text("Choose device").tag(UUID?.none)
343
                    ForEach(availableChargedDevices) { device in
344
                        Text(device.name).tag(Optional(device.id))
345
                    }
346
                } label: {
347
                    HStack(spacing: 8) {
348
                        if let device = selectedChargedDevice {
349
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
350
                                .font(.subheadline.weight(.semibold))
351
                        } else {
352
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
353
                                .foregroundColor(.secondary)
354
                                .font(.subheadline)
355
                        }
356
                        Spacer(minLength: 8)
357
                        Image(systemName: "chevron.up.chevron.down")
358
                            .font(.caption.weight(.semibold))
359
                            .foregroundColor(.secondary)
360
                    }
Bogdan Timofte authored a month ago
361
                }
Bogdan Timofte authored a month ago
362
                .pickerStyle(.menu)
363
                .disabled(availableChargedDevices.isEmpty)
Bogdan Timofte authored a month ago
364
            }
365

            
Bogdan Timofte authored a month ago
366
            // Charging type — only when device supports multiple
367
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
368
                Divider().padding(.leading, 46)
369
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
370
                    Text("Type")
371
                        .foregroundColor(.secondary)
372
                        .font(.subheadline)
373
                    Spacer()
Bogdan Timofte authored a month ago
374
                    compactSelectionMenu(
375
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
376
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
377
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
378
                                id: mode.id, title: mode.title,
379
                                isSelected: draftChargingTransportMode == mode,
380
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
381
                            )
Bogdan Timofte authored a month ago
382
                        }
Bogdan Timofte authored a month ago
383
                    )
Bogdan Timofte authored a month ago
384
                }
Bogdan Timofte authored a month ago
385
            }
Bogdan Timofte authored a month ago
386

            
Bogdan Timofte authored a month ago
387
            // Charging state — only when device supports multiple
388
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
389
                Divider().padding(.leading, 46)
390
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
391
                    Text("Mode")
392
                        .foregroundColor(.secondary)
393
                        .font(.subheadline)
394
                    Spacer()
Bogdan Timofte authored a month ago
395
                    compactSelectionMenu(
396
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
397
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
398
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
399
                                id: mode.id, title: mode.title,
400
                                isSelected: draftChargingStateMode == mode,
401
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
402
                            )
Bogdan Timofte authored a month ago
403
                        }
Bogdan Timofte authored a month ago
404
                    )
Bogdan Timofte authored a month ago
405
                }
406
            }
Bogdan Timofte authored a month ago
407

            
Bogdan Timofte authored a month ago
408
            // Wireless charger — only when wireless transport
409
            if showsWirelessChargerSection {
410
                Divider().padding(.leading, 46)
411
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
412
                    Picker(selection: selectedChargerID) {
413
                        Text("Choose charger").tag(UUID?.none)
414
                        ForEach(availableChargers) { charger in
415
                            Text(charger.name).tag(Optional(charger.id))
416
                        }
417
                    } label: {
418
                        HStack(spacing: 8) {
419
                            if let charger = selectedCharger {
420
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
421
                                    .font(.subheadline.weight(.semibold))
422
                            } else {
423
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
424
                                    .foregroundColor(.secondary)
425
                                    .font(.subheadline)
426
                            }
427
                            Spacer(minLength: 8)
428
                            Image(systemName: "chevron.up.chevron.down")
429
                                .font(.caption.weight(.semibold))
430
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
431
                        }
Bogdan Timofte authored a month ago
432
                    }
Bogdan Timofte authored a month ago
433
                    .pickerStyle(.menu)
434
                    .disabled(availableChargers.isEmpty)
Bogdan Timofte authored a month ago
435
                }
Bogdan Timofte authored a month ago
436
            }
437

            
Bogdan Timofte authored a month ago
438
            // Battery checkpoint
439
            Divider().padding(.leading, 46)
440
            setupRow(icon: "battery.75percent", iconColor: .green) {
441
                if initialCheckpointMode == .known {
442
                    Button { adjustInitialCheckpoint(by: -1) } label: {
443
                        Image(systemName: "minus.circle").font(.title3)
444
                    }
445
                    .buttonStyle(.plain)
446

            
447
                    TextField("—", text: $initialCheckpoint)
448
                        .keyboardType(.decimalPad)
449
                        .textFieldStyle(.roundedBorder)
450
                        .frame(width: 52)
451
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
452

            
Bogdan Timofte authored a month ago
453
                    Text("%")
454
                        .font(.subheadline)
455
                        .foregroundColor(.secondary)
456

            
457
                    Button { adjustInitialCheckpoint(by: 1) } label: {
458
                        Image(systemName: "plus.circle").font(.title3)
459
                    }
460
                    .buttonStyle(.plain)
461
                } else {
462
                    Text(initialCheckpointMode == .flat
463
                         ? "Flat (device off / discharged)"
464
                         : "Unknown")
465
                        .font(.subheadline)
466
                        .foregroundColor(.secondary)
467
                }
468
                Spacer()
Bogdan Timofte authored a month ago
469
                compactSelectionMenu(
470
                    title: initialCheckpointMode.title,
471
                    options: InitialCheckpointMode.allCases.map { mode in
472
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
473
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
474
                            isSelected: initialCheckpointMode == mode,
475
                            action: { initialCheckpointMode = mode }
476
                        )
477
                    }
478
                )
Bogdan Timofte authored a month ago
479
            }
480

            
Bogdan Timofte authored a month ago
481
            // Requirement errors
482
            if startRequirements.isEmpty == false {
483
                Divider()
484
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
485
                    ForEach(startRequirements) { requirement in
486
                        Label(requirement.message, systemImage: "exclamationmark.circle")
487
                            .font(.caption)
488
                            .foregroundColor(.orange)
489
                    }
490
                }
Bogdan Timofte authored a month ago
491
                .padding(.horizontal, 14)
492
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
493
            }
Bogdan Timofte authored a month ago
494

            
Bogdan Timofte authored a month ago
495
            // Start button
496
            Divider()
Bogdan Timofte authored a month ago
497
            Button("Start Session") {
498
                startSession()
499
            }
500
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
501
            .padding(.vertical, 11)
502
            .font(.subheadline.weight(.semibold))
503
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
504
            .buttonStyle(.plain)
505
            .disabled(!canStartSession)
506
        }
Bogdan Timofte authored a month ago
507
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
508
    }
509

            
Bogdan Timofte authored a month ago
510
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
511

            
Bogdan Timofte authored a month ago
512
    private var standbyPowerCard: some View {
513
        VStack(alignment: .leading, spacing: 12) {
514
            HStack(spacing: 10) {
515
                Image(systemName: "powersleep")
516
                    .foregroundColor(.orange)
517
                    .font(.title3)
518
                VStack(alignment: .leading, spacing: 2) {
519
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
520
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
521
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
522
                        .font(.caption)
523
                        .foregroundColor(.secondary)
524
                }
Bogdan Timofte authored a month ago
525
            }
Bogdan Timofte authored a month ago
526

            
Bogdan Timofte authored a month ago
527
            NavigationLink(
528
                destination: ChargerStandbyPowerWizardView(
529
                    preferredMeterMACAddress: meterMACAddress
530
                )
531
            ) {
532
                HStack {
533
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
534
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
535
                    Text("New Measurement")
Bogdan Timofte authored a month ago
536
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
537
                    Spacer()
538
                    Image(systemName: "chevron.right")
539
                        .font(.caption.weight(.semibold))
540
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
541
                }
Bogdan Timofte authored a month ago
542
                .padding(.vertical, 10)
543
                .padding(.horizontal, 14)
544
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
545
            }
Bogdan Timofte authored a month ago
546
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
547
        }
Bogdan Timofte authored a month ago
548
        .padding(18)
549
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
550
    }
551

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

            
554
    private var liveMeterStripView: some View {
555
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
556
        return LazyVGrid(columns: columns, spacing: 8) {
557
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
558
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
559
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
560
        }
561
    }
562

            
563
    private func metricCell(label: String, value: String, tint: Color) -> some View {
564
        VStack(alignment: .leading, spacing: 3) {
565
            Text(label)
566
                .font(.caption2)
567
                .foregroundColor(.secondary)
568
            Text(value)
569
                .font(.subheadline.weight(.semibold))
570
                .lineLimit(1)
571
                .minimumScaleFactor(0.7)
572
                .monospacedDigit()
573
        }
574
        .frame(maxWidth: .infinity, alignment: .leading)
575
        .padding(.horizontal, 12)
576
        .padding(.vertical, 10)
577
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
578
    }
579

            
Bogdan Timofte authored a month ago
580
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
581
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
582
            HStack(spacing: 8) {
583
                Text("Meter Recorder")
584
                    .font(.headline)
585

            
586
                Spacer(minLength: 0)
587

            
588
                Button {
589
                    showsMeterTotalsInfo.toggle()
590
                } label: {
591
                    Image(systemName: "info.circle")
592
                        .font(.body.weight(.semibold))
593
                        .foregroundColor(.secondary)
594
                }
595
                .buttonStyle(.plain)
596
                .accessibilityLabel("Meter recorder info")
597
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
598
                    VStack(alignment: .leading, spacing: 10) {
599
                        Text("Meter Recorder")
600
                            .font(.headline)
601
                        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.")
602
                            .font(.body)
603
                            .fixedSize(horizontal: false, vertical: true)
604
                    }
605
                    .padding(16)
606
                    .frame(width: 280, alignment: .leading)
607
                }
608
            }
Bogdan Timofte authored a month ago
609

            
610
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
611
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
612
                values: [
613
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
614
                    usbMeter.recordingDurationDescription,
615
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
616
                ]
617
            )
Bogdan Timofte authored a month ago
618

            
619
            if let recordingBootedAt = usbMeter.recordingBootedAt {
620
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
621
                    .font(.caption)
622
                    .foregroundColor(.secondary)
623
            }
Bogdan Timofte authored a month ago
624
        }
625
        .padding(18)
626
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
627
    }
628

            
Bogdan Timofte authored a month ago
629
    // MARK: - Helpers
630

            
631
    private func setupRow<Content: View>(
632
        icon: String,
633
        iconColor: Color = .secondary,
634
        @ViewBuilder content: () -> Content
635
    ) -> some View {
636
        HStack(spacing: 10) {
637
            Image(systemName: icon)
638
                .foregroundColor(iconColor)
639
                .font(.body.weight(.medium))
640
                .frame(width: 22, alignment: .center)
641
            content()
642
        }
643
        .padding(.horizontal, 14)
644
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
645
    }
646

            
647
    private func startSession() {
648
        guard let selectedChargedDevice,
649
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
650
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
651
            return
652
        }
653

            
654
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
655
        let didStart = appData.startChargeSession(
656
            for: usbMeter,
657
            chargedDeviceID: selectedChargedDevice.id,
658
            chargerID: chargerID,
659
            chargingTransportMode: chargingTransportMode,
660
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
661
            autoStopEnabled: false,
662
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
663
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
664
        )
Bogdan Timofte authored a month ago
665

            
666
        if didStart {
667
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
668
            initialCheckpointMode = .known
669
        }
670
    }
671

            
672
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
673
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
674
        let currentValue = initialCheckpointValue ?? 0
675
        let nextValue = min(max(currentValue + delta, 0), 100)
676
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
677
    }
678

            
Bogdan Timofte authored a month ago
679
    private func syncDraftSelections() {
680
        guard let selectedChargedDevice else {
681
            draftChargingTransportMode = nil
682
            draftChargingStateMode = nil
683
            return
684
        }
685

            
686
        if let openChargeSession {
687
            draftChargingTransportMode = openChargeSession.chargingTransportMode
688
            draftChargingStateMode = openChargeSession.chargingStateMode
689
            return
690
        }
691

            
692
        if let draftChargingTransportMode,
693
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
694
            self.draftChargingTransportMode = nil
695
        }
696

            
697
        if let draftChargingStateMode,
698
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
699
            self.draftChargingStateMode = nil
700
        }
701

            
702
        if selectedChargedDevice.supportedChargingModes.count == 1 {
703
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
704
        }
705

            
Bogdan Timofte authored a month ago
706
        if let draftChargingTransportMode {
707
            draftChargingStateMode = draftChargingStateMode
708
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
709
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
710
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
711
        }
Bogdan Timofte authored a month ago
712
    }
Bogdan Timofte authored a month ago
713

            
714
    private struct CompactSelectionOption: Identifiable {
715
        let id: String
716
        let title: String
717
        let isSelected: Bool
718
        let action: () -> Void
719
    }
720

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