USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
759 lines | 29.096kb
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
            // Wireless charger — appears immediately after Type when wireless is selected
Bogdan Timofte authored a month ago
388
            if showsWirelessChargerSection {
389
                Divider().padding(.leading, 46)
Bogdan Timofte authored a month ago
390
                    .transition(.opacity)
Bogdan Timofte authored a month ago
391
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
392
                    Picker(selection: selectedChargerID) {
393
                        Text("Choose charger").tag(UUID?.none)
394
                        ForEach(availableChargers) { charger in
395
                            Text(charger.name).tag(Optional(charger.id))
396
                        }
397
                    } label: {
398
                        HStack(spacing: 8) {
399
                            if let charger = selectedCharger {
400
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
401
                                    .font(.subheadline.weight(.semibold))
402
                            } else {
403
                                Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger")
404
                                    .foregroundColor(.secondary)
405
                                    .font(.subheadline)
406
                            }
407
                            Spacer(minLength: 8)
408
                            Image(systemName: "chevron.up.chevron.down")
409
                                .font(.caption.weight(.semibold))
410
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
411
                        }
Bogdan Timofte authored a month ago
412
                    }
Bogdan Timofte authored a month ago
413
                    .pickerStyle(.menu)
414
                    .disabled(availableChargers.isEmpty)
Bogdan Timofte authored a month ago
415
                }
Bogdan Timofte authored a month ago
416
                .transition(.asymmetric(
417
                    insertion: .move(edge: .top).combined(with: .opacity),
418
                    removal: .opacity
419
                ))
420
            }
421

            
422
            // Charging state — only when device supports multiple
423
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
424
                Divider().padding(.leading, 46)
425
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
426
                    Text("Mode")
427
                        .foregroundColor(.secondary)
428
                        .font(.subheadline)
429
                    Spacer()
430
                    compactSelectionMenu(
431
                        title: draftChargingStateMode?.title ?? "Choose",
432
                        options: device.supportedChargingStateModes.map { mode in
433
                            CompactSelectionOption(
434
                                id: mode.id, title: mode.title,
435
                                isSelected: draftChargingStateMode == mode,
436
                                action: { draftChargingStateMode = mode }
437
                            )
438
                        }
439
                    )
440
                }
Bogdan Timofte authored a month ago
441
            }
442

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

            
452
                    TextField("—", text: $initialCheckpoint)
453
                        .keyboardType(.decimalPad)
454
                        .textFieldStyle(.roundedBorder)
455
                        .frame(width: 52)
456
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
457

            
Bogdan Timofte authored a month ago
458
                    Text("%")
459
                        .font(.subheadline)
460
                        .foregroundColor(.secondary)
461

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

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

            
Bogdan Timofte authored a month ago
500
            // Start button
501
            Divider()
Bogdan Timofte authored a month ago
502
            Button("Start Session") {
503
                startSession()
504
            }
505
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
506
            .padding(.vertical, 11)
507
            .font(.subheadline.weight(.semibold))
508
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
509
            .buttonStyle(.plain)
510
            .disabled(!canStartSession)
511
        }
Bogdan Timofte authored a month ago
512
        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection)
Bogdan Timofte authored a month ago
513
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
514
    }
515

            
Bogdan Timofte authored a month ago
516
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
517

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

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

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

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

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

            
Bogdan Timofte authored a month ago
586
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
587
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
588
            HStack(spacing: 8) {
589
                Text("Meter Recorder")
590
                    .font(.headline)
591

            
592
                Spacer(minLength: 0)
593

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

            
616
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
617
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
618
                values: [
619
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
620
                    usbMeter.recordingDurationDescription,
621
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
622
                ]
623
            )
Bogdan Timofte authored a month ago
624

            
625
            if let recordingBootedAt = usbMeter.recordingBootedAt {
626
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
627
                    .font(.caption)
628
                    .foregroundColor(.secondary)
629
            }
Bogdan Timofte authored a month ago
630
        }
631
        .padding(18)
632
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
633
    }
634

            
Bogdan Timofte authored a month ago
635
    // MARK: - Helpers
636

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

            
653
    private func startSession() {
654
        guard let selectedChargedDevice,
655
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
656
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
657
            return
658
        }
659

            
660
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
661
        let didStart = appData.startChargeSession(
662
            for: usbMeter,
663
            chargedDeviceID: selectedChargedDevice.id,
664
            chargerID: chargerID,
665
            chargingTransportMode: chargingTransportMode,
666
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
667
            autoStopEnabled: false,
668
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
669
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
670
        )
Bogdan Timofte authored a month ago
671

            
672
        if didStart {
673
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
674
            initialCheckpointMode = .known
675
        }
676
    }
677

            
678
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
679
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
680
        let currentValue = initialCheckpointValue ?? 0
681
        let nextValue = min(max(currentValue + delta, 0), 100)
682
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
683
    }
684

            
Bogdan Timofte authored a month ago
685
    private func syncDraftSelections() {
686
        guard let selectedChargedDevice else {
687
            draftChargingTransportMode = nil
688
            draftChargingStateMode = nil
689
            return
690
        }
691

            
692
        if let openChargeSession {
693
            draftChargingTransportMode = openChargeSession.chargingTransportMode
694
            draftChargingStateMode = openChargeSession.chargingStateMode
695
            return
696
        }
697

            
698
        if let draftChargingTransportMode,
699
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
700
            self.draftChargingTransportMode = nil
701
        }
702

            
703
        if let draftChargingStateMode,
704
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
705
            self.draftChargingStateMode = nil
706
        }
707

            
708
        if selectedChargedDevice.supportedChargingModes.count == 1 {
709
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
710
        }
711

            
Bogdan Timofte authored a month ago
712
        if let draftChargingTransportMode {
713
            draftChargingStateMode = draftChargingStateMode
714
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
715
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
716
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
717
        }
Bogdan Timofte authored a month ago
718
    }
Bogdan Timofte authored a month ago
719

            
720
    private struct CompactSelectionOption: Identifiable {
721
        let id: String
722
        let title: String
723
        let isSelected: Bool
724
        let action: () -> Void
725
    }
726

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