USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
841 lines | 32.822kb
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
    @State private var draftSourcePowerbankID: UUID?
Bogdan Timofte authored a month ago
86

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
181
    private var availablePowerbanks: [PowerbankSummary] {
182
        appData.powerbankSummaries
183
    }
184

            
185
    private var selectedSourcePowerbank: PowerbankSummary? {
186
        if let openChargeSession,
187
           let powerbankID = openChargeSession.sourcePowerbankID {
188
            return availablePowerbanks.first { $0.id == powerbankID }
189
        }
190
        guard let draftSourcePowerbankID else { return nil }
191
        return availablePowerbanks.first { $0.id == draftSourcePowerbankID }
192
    }
193

            
194
    /// Unified source selection encoding — packed into a String tag because SwiftUI Picker
195
    /// works best with hashable primitives. `none`, `charger:UUID`, or `powerbank:UUID`.
196
    private var selectedSourceTag: Binding<String> {
197
        Binding(
198
            get: {
199
                if let openChargeSession {
200
                    if let chargerID = openChargeSession.chargerID { return "charger:\(chargerID.uuidString)" }
201
                    if let powerbankID = openChargeSession.sourcePowerbankID { return "powerbank:\(powerbankID.uuidString)" }
202
                    return "none"
203
                }
204
                if let draftChargerID { return "charger:\(draftChargerID.uuidString)" }
205
                if let draftSourcePowerbankID { return "powerbank:\(draftSourcePowerbankID.uuidString)" }
206
                return "none"
207
            },
208
            set: { newValue in
209
                if newValue == "none" {
210
                    draftChargerID = nil
211
                    draftSourcePowerbankID = nil
212
                } else if newValue.hasPrefix("charger:"),
213
                          let uuid = UUID(uuidString: String(newValue.dropFirst("charger:".count))) {
214
                    draftChargerID = uuid
215
                    draftSourcePowerbankID = nil
216
                } else if newValue.hasPrefix("powerbank:"),
217
                          let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
218
                    draftChargerID = nil
219
                    draftSourcePowerbankID = uuid
220
                }
221
            }
222
        )
223
    }
224

            
225
    private var hasAnySource: Bool {
226
        availableChargers.isEmpty == false || availablePowerbanks.isEmpty == false
227
    }
228

            
Bogdan Timofte authored a month ago
229
    private var selectedChargerID: Binding<UUID?> {
230
        Binding(
Bogdan Timofte authored a month ago
231
            get: { openChargeSession?.chargerID ?? draftChargerID },
Bogdan Timofte authored a month ago
232
            set: { newValue in
Bogdan Timofte authored a month ago
233
                draftChargerID = newValue
Bogdan Timofte authored a month ago
234
            }
235
        )
236
    }
237

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
325
        return requirements
326
    }
327

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

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

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

            
Bogdan Timofte authored a month ago
347
    private var showsWirelessChargerSection: Bool {
348
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
349
        return transportMode == .wireless
350
    }
Bogdan Timofte authored a month ago
351

            
Bogdan Timofte authored a month ago
352
    /// Source section is visible whenever a transport is picked. For wired sessions only
353
    /// powerbanks are listed (chargers don't apply); for wireless both chargers and powerbanks
354
    /// can be picked.
355
    private var showsSourceSection: Bool {
356
        guard selectedDraftTransportMode != nil || selectedChargedDevice != nil else { return false }
357
        if showsWirelessChargerSection {
358
            return hasAnySource
359
        }
360
        return availablePowerbanks.isEmpty == false
361
    }
362

            
363
    private var sourceSectionListsChargers: Bool {
364
        showsWirelessChargerSection
365
    }
366

            
367
    private var sourcePromptText: String {
368
        if showsWirelessChargerSection {
369
            return availableChargers.isEmpty && availablePowerbanks.isEmpty
370
                ? "No source available"
371
                : "Choose source"
372
        }
373
        return availablePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)"
374
    }
375

            
Bogdan Timofte authored a month ago
376
    // MARK: - Status Header
377

            
378
    private var statusHeader: some View {
379
        HStack {
380
            Image(systemName: "bolt.fill")
381
                .foregroundColor(.pink)
382
            Text("Charging Session")
383
                .font(.system(.title3, design: .rounded).weight(.bold))
384
            Spacer()
385
            Text(headerStatusTitle)
386
                .font(.caption.weight(.bold))
387
                .foregroundColor(headerStatusColor)
388
                .padding(.horizontal, 10)
389
                .padding(.vertical, 6)
390
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
391
        }
392
        .padding(.horizontal, 18)
393
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
394
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
395
    }
396

            
Bogdan Timofte authored a month ago
397
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
398

            
Bogdan Timofte authored a month ago
399
    private var modePicker: some View {
400
        Picker("", selection: $activeMode) {
401
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
402
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
403
        }
Bogdan Timofte authored a month ago
404
        .pickerStyle(.segmented)
405
        .labelsHidden()
Bogdan Timofte authored a month ago
406
    }
407

            
Bogdan Timofte authored a month ago
408
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
409

            
Bogdan Timofte authored a month ago
410
    private var chargeSessionSetupCard: some View {
411
        VStack(alignment: .leading, spacing: 0) {
412
            // Device
413
            setupRow(icon: "iphone", iconColor: .blue) {
Bogdan Timofte authored a month ago
414
                Picker(selection: selectedChargedDeviceID) {
415
                    Text("Choose device").tag(UUID?.none)
416
                    ForEach(availableChargedDevices) { device in
417
                        Text(device.name).tag(Optional(device.id))
418
                    }
419
                } label: {
420
                    HStack(spacing: 8) {
421
                        if let device = selectedChargedDevice {
422
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
423
                                .font(.subheadline.weight(.semibold))
424
                        } else {
425
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
426
                                .foregroundColor(.secondary)
427
                                .font(.subheadline)
428
                        }
429
                        Spacer(minLength: 8)
430
                        Image(systemName: "chevron.up.chevron.down")
431
                            .font(.caption.weight(.semibold))
432
                            .foregroundColor(.secondary)
433
                    }
Bogdan Timofte authored a month ago
434
                }
Bogdan Timofte authored a month ago
435
                .pickerStyle(.menu)
436
                .disabled(availableChargedDevices.isEmpty)
Bogdan Timofte authored a month ago
437
            }
438

            
Bogdan Timofte authored a month ago
439
            // Charging type — only when device supports multiple
440
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
441
                Divider().padding(.leading, 46)
442
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
443
                    Text("Type")
444
                        .foregroundColor(.secondary)
445
                        .font(.subheadline)
446
                    Spacer()
Bogdan Timofte authored a month ago
447
                    compactSelectionMenu(
448
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
449
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
450
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
451
                                id: mode.id, title: mode.title,
452
                                isSelected: draftChargingTransportMode == mode,
453
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
454
                            )
Bogdan Timofte authored a month ago
455
                        }
Bogdan Timofte authored a month ago
456
                    )
Bogdan Timofte authored a month ago
457
                }
Bogdan Timofte authored a month ago
458
            }
Bogdan Timofte authored a month ago
459

            
Bogdan Timofte authored a month ago
460
            // Source — charger (when wireless) and/or powerbank. None is always allowed.
461
            if showsSourceSection {
Bogdan Timofte authored a month ago
462
                Divider().padding(.leading, 46)
Bogdan Timofte authored a month ago
463
                    .transition(.opacity)
Bogdan Timofte authored a month ago
464
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
465
                    Picker(selection: selectedSourceTag) {
466
                        Text("None").tag("none")
467
                        if sourceSectionListsChargers {
468
                            ForEach(availableChargers) { charger in
469
                                Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)")
470
                            }
471
                        }
472
                        ForEach(availablePowerbanks) { powerbank in
473
                            Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
Bogdan Timofte authored a month ago
474
                        }
475
                    } label: {
476
                        HStack(spacing: 8) {
477
                            if let charger = selectedCharger {
478
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
479
                                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
480
                            } else if let powerbank = selectedSourcePowerbank {
481
                                Label(powerbank.name, systemImage: powerbank.identitySymbolName)
482
                                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
483
                            } else {
Bogdan Timofte authored a month ago
484
                                Text(sourcePromptText)
Bogdan Timofte authored a month ago
485
                                    .foregroundColor(.secondary)
486
                                    .font(.subheadline)
487
                            }
488
                            Spacer(minLength: 8)
489
                            Image(systemName: "chevron.up.chevron.down")
490
                                .font(.caption.weight(.semibold))
491
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
492
                        }
Bogdan Timofte authored a month ago
493
                    }
Bogdan Timofte authored a month ago
494
                    .pickerStyle(.menu)
Bogdan Timofte authored a month ago
495
                }
Bogdan Timofte authored a month ago
496
                .transition(.asymmetric(
497
                    insertion: .move(edge: .top).combined(with: .opacity),
498
                    removal: .opacity
499
                ))
500
            }
501

            
502
            // Charging state — only when device supports multiple
503
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
504
                Divider().padding(.leading, 46)
505
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
506
                    Text("Mode")
507
                        .foregroundColor(.secondary)
508
                        .font(.subheadline)
509
                    Spacer()
510
                    compactSelectionMenu(
511
                        title: draftChargingStateMode?.title ?? "Choose",
512
                        options: device.supportedChargingStateModes.map { mode in
513
                            CompactSelectionOption(
514
                                id: mode.id, title: mode.title,
515
                                isSelected: draftChargingStateMode == mode,
516
                                action: { draftChargingStateMode = mode }
517
                            )
518
                        }
519
                    )
520
                }
Bogdan Timofte authored a month ago
521
            }
522

            
Bogdan Timofte authored a month ago
523
            // Battery checkpoint
524
            Divider().padding(.leading, 46)
525
            setupRow(icon: "battery.75percent", iconColor: .green) {
526
                if initialCheckpointMode == .known {
527
                    Button { adjustInitialCheckpoint(by: -1) } label: {
528
                        Image(systemName: "minus.circle").font(.title3)
529
                    }
530
                    .buttonStyle(.plain)
531

            
532
                    TextField("—", text: $initialCheckpoint)
533
                        .keyboardType(.decimalPad)
534
                        .textFieldStyle(.roundedBorder)
535
                        .frame(width: 52)
536
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
537

            
Bogdan Timofte authored a month ago
538
                    Text("%")
539
                        .font(.subheadline)
540
                        .foregroundColor(.secondary)
541

            
542
                    Button { adjustInitialCheckpoint(by: 1) } label: {
543
                        Image(systemName: "plus.circle").font(.title3)
544
                    }
545
                    .buttonStyle(.plain)
546
                } else {
547
                    Text(initialCheckpointMode == .flat
548
                         ? "Flat (device off / discharged)"
549
                         : "Unknown")
550
                        .font(.subheadline)
551
                        .foregroundColor(.secondary)
552
                }
553
                Spacer()
Bogdan Timofte authored a month ago
554
                compactSelectionMenu(
555
                    title: initialCheckpointMode.title,
556
                    options: InitialCheckpointMode.allCases.map { mode in
557
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
558
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
559
                            isSelected: initialCheckpointMode == mode,
560
                            action: { initialCheckpointMode = mode }
561
                        )
562
                    }
563
                )
Bogdan Timofte authored a month ago
564
            }
565

            
Bogdan Timofte authored a month ago
566
            // Requirement errors
567
            if startRequirements.isEmpty == false {
568
                Divider()
569
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
570
                    ForEach(startRequirements) { requirement in
571
                        Label(requirement.message, systemImage: "exclamationmark.circle")
572
                            .font(.caption)
573
                            .foregroundColor(.orange)
574
                    }
575
                }
Bogdan Timofte authored a month ago
576
                .padding(.horizontal, 14)
577
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
578
            }
Bogdan Timofte authored a month ago
579

            
Bogdan Timofte authored a month ago
580
            // Start button
581
            Divider()
Bogdan Timofte authored a month ago
582
            Button("Start Session") {
583
                startSession()
584
            }
585
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
586
            .padding(.vertical, 11)
587
            .font(.subheadline.weight(.semibold))
588
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
589
            .buttonStyle(.plain)
590
            .disabled(!canStartSession)
591
        }
Bogdan Timofte authored a month ago
592
        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection)
Bogdan Timofte authored a month ago
593
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
594
    }
595

            
Bogdan Timofte authored a month ago
596
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
597

            
Bogdan Timofte authored a month ago
598
    private var standbyPowerCard: some View {
599
        VStack(alignment: .leading, spacing: 12) {
600
            HStack(spacing: 10) {
601
                Image(systemName: "powersleep")
602
                    .foregroundColor(.orange)
603
                    .font(.title3)
604
                VStack(alignment: .leading, spacing: 2) {
605
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
606
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
607
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
608
                        .font(.caption)
609
                        .foregroundColor(.secondary)
610
                }
Bogdan Timofte authored a month ago
611
            }
Bogdan Timofte authored a month ago
612

            
Bogdan Timofte authored a month ago
613
            NavigationLink(
614
                destination: ChargerStandbyPowerWizardView(
615
                    preferredMeterMACAddress: meterMACAddress
616
                )
617
            ) {
618
                HStack {
619
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
620
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
621
                    Text("New Measurement")
Bogdan Timofte authored a month ago
622
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
623
                    Spacer()
624
                    Image(systemName: "chevron.right")
625
                        .font(.caption.weight(.semibold))
626
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
627
                }
Bogdan Timofte authored a month ago
628
                .padding(.vertical, 10)
629
                .padding(.horizontal, 14)
630
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
631
            }
Bogdan Timofte authored a month ago
632
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
633
        }
Bogdan Timofte authored a month ago
634
        .padding(18)
635
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
636
    }
637

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

            
640
    private var liveMeterStripView: some View {
641
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
642
        return LazyVGrid(columns: columns, spacing: 8) {
643
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
644
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
645
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
646
        }
647
    }
648

            
649
    private func metricCell(label: String, value: String, tint: Color) -> some View {
650
        VStack(alignment: .leading, spacing: 3) {
651
            Text(label)
652
                .font(.caption2)
653
                .foregroundColor(.secondary)
654
            Text(value)
655
                .font(.subheadline.weight(.semibold))
656
                .lineLimit(1)
657
                .minimumScaleFactor(0.7)
658
                .monospacedDigit()
659
        }
660
        .frame(maxWidth: .infinity, alignment: .leading)
661
        .padding(.horizontal, 12)
662
        .padding(.vertical, 10)
663
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
664
    }
665

            
Bogdan Timofte authored a month ago
666
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
667
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
668
            HStack(spacing: 8) {
669
                Text("Meter Recorder")
670
                    .font(.headline)
671

            
672
                Spacer(minLength: 0)
673

            
674
                Button {
675
                    showsMeterTotalsInfo.toggle()
676
                } label: {
677
                    Image(systemName: "info.circle")
678
                        .font(.body.weight(.semibold))
679
                        .foregroundColor(.secondary)
680
                }
681
                .buttonStyle(.plain)
682
                .accessibilityLabel("Meter recorder info")
683
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
684
                    VStack(alignment: .leading, spacing: 10) {
685
                        Text("Meter Recorder")
686
                            .font(.headline)
687
                        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.")
688
                            .font(.body)
689
                            .fixedSize(horizontal: false, vertical: true)
690
                    }
691
                    .padding(16)
692
                    .frame(width: 280, alignment: .leading)
693
                }
694
            }
Bogdan Timofte authored a month ago
695

            
696
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
697
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
698
                values: [
699
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
700
                    usbMeter.recordingDurationDescription,
701
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
702
                ]
703
            )
Bogdan Timofte authored a month ago
704

            
705
            if let recordingBootedAt = usbMeter.recordingBootedAt {
706
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
707
                    .font(.caption)
708
                    .foregroundColor(.secondary)
709
            }
Bogdan Timofte authored a month ago
710
        }
711
        .padding(18)
712
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
713
    }
714

            
Bogdan Timofte authored a month ago
715
    // MARK: - Helpers
716

            
717
    private func setupRow<Content: View>(
718
        icon: String,
719
        iconColor: Color = .secondary,
720
        @ViewBuilder content: () -> Content
721
    ) -> some View {
722
        HStack(spacing: 10) {
723
            Image(systemName: icon)
724
                .foregroundColor(iconColor)
725
                .font(.body.weight(.medium))
726
                .frame(width: 22, alignment: .center)
727
            content()
728
        }
729
        .padding(.horizontal, 14)
730
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
731
    }
732

            
733
    private func startSession() {
734
        guard let selectedChargedDevice,
735
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
736
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
737
            return
738
        }
739

            
740
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
Bogdan Timofte authored a month ago
741
        let powerbankSourceID = selectedSourcePowerbank?.id
Bogdan Timofte authored a month ago
742
        let didStart = appData.startChargeSession(
743
            for: usbMeter,
744
            chargedDeviceID: selectedChargedDevice.id,
745
            chargerID: chargerID,
Bogdan Timofte authored a month ago
746
            sourcePowerbankID: powerbankSourceID,
Bogdan Timofte authored a month ago
747
            chargingTransportMode: chargingTransportMode,
748
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
749
            autoStopEnabled: false,
750
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
751
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
752
        )
Bogdan Timofte authored a month ago
753

            
754
        if didStart {
755
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
756
            initialCheckpointMode = .known
757
        }
758
    }
759

            
760
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
761
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
762
        let currentValue = initialCheckpointValue ?? 0
763
        let nextValue = min(max(currentValue + delta, 0), 100)
764
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
765
    }
766

            
Bogdan Timofte authored a month ago
767
    private func syncDraftSelections() {
768
        guard let selectedChargedDevice else {
769
            draftChargingTransportMode = nil
770
            draftChargingStateMode = nil
771
            return
772
        }
773

            
774
        if let openChargeSession {
775
            draftChargingTransportMode = openChargeSession.chargingTransportMode
776
            draftChargingStateMode = openChargeSession.chargingStateMode
777
            return
778
        }
779

            
780
        if let draftChargingTransportMode,
781
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
782
            self.draftChargingTransportMode = nil
783
        }
784

            
785
        if let draftChargingStateMode,
786
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
787
            self.draftChargingStateMode = nil
788
        }
789

            
790
        if selectedChargedDevice.supportedChargingModes.count == 1 {
791
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
792
        }
793

            
Bogdan Timofte authored a month ago
794
        if let draftChargingTransportMode {
795
            draftChargingStateMode = draftChargingStateMode
796
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
797
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
798
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
799
        }
Bogdan Timofte authored a month ago
800
    }
Bogdan Timofte authored a month ago
801

            
802
    private struct CompactSelectionOption: Identifiable {
803
        let id: String
804
        let title: String
805
        let isSelected: Bool
806
        let action: () -> Void
807
    }
808

            
809
    private func compactSelectionMenu(
810
        title: String,
811
        options: [CompactSelectionOption]
812
    ) -> some View {
813
        Menu {
814
            ForEach(options) { option in
815
                Button {
816
                    option.action()
817
                } label: {
818
                    if option.isSelected {
819
                        Label(option.title, systemImage: "checkmark")
820
                    } else {
821
                        Text(option.title)
822
                    }
823
                }
824
            }
825
        } label: {
826
            HStack(spacing: 8) {
827
                Text(title)
828
                    .foregroundColor(.primary)
829
                Spacer()
830
                Image(systemName: "chevron.up.chevron.down")
831
                    .font(.caption.weight(.semibold))
832
                    .foregroundColor(.secondary)
833
            }
834
            .padding(.horizontal, 12)
835
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
836
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
837
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
838
        }
839
        .buttonStyle(.plain)
840
    }
Bogdan Timofte authored a month ago
841
}