USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1171 lines | 46.897kb
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
Bogdan Timofte authored a month ago
38
        case consumptionMonitor
Bogdan Timofte authored a month ago
39
    }
Bogdan Timofte authored a month ago
40

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

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

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

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

            
78
    @State private var draftChargingTransportMode: ChargingTransportMode?
79
    @State private var draftChargingStateMode: ChargingStateMode?
80
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
81
    @State private var initialCheckpoint = ""
82
    @State private var showsMeterTotalsInfo = false
83
    @State private var activeMode: ActiveMode = .chargeSession
Bogdan Timofte authored a month ago
84
    @State private var draftChargedDeviceID: UUID?
Bogdan Timofte authored a month ago
85
    @State private var draftChargedPowerbankID: UUID?
Bogdan Timofte authored a month ago
86
    @State private var draftChargerID: UUID?
Bogdan Timofte authored a month ago
87
    @State private var draftSourcePowerbankID: UUID?
Bogdan Timofte authored a month ago
88
    @State private var draftConsumptionDeviceID: UUID?
89
    @State private var discardConsumptionConfirmation = false
Bogdan Timofte authored a month ago
90

            
Bogdan Timofte authored a month ago
91
    var body: some View {
Bogdan Timofte authored a month ago
92
        Group {
93
            if let openChargeSession {
94
                ChargeSessionDetailView(
95
                    chargedDeviceID: openChargeSession.chargedDeviceID,
96
                    sessionID: openChargeSession.id,
97
                    monitoringMeter: usbMeter,
98
                    presentation: .embedded
99
                )
Bogdan Timofte authored a month ago
100
            } else if activeMode == .consumptionMonitor, let session = activeConsumptionSession {
101
                consumptionSessionActiveView(session)
Bogdan Timofte authored a month ago
102
            } else {
103
                ScrollView {
104
                    VStack(spacing: 14) {
105
                        statusHeader
106
                        liveMeterStripView
107
                        modePicker
108

            
109
                        switch activeMode {
110
                        case .chargeSession:
111
                            chargeSessionSetupCard
112
                        case .standbyPower:
113
                            standbyPowerCard
Bogdan Timofte authored a month ago
114
                        case .consumptionMonitor:
115
                            consumptionMonitorSetupCard
Bogdan Timofte authored a month ago
116
                        }
Bogdan Timofte authored a month ago
117
                    }
Bogdan Timofte authored a month ago
118
                    .padding()
Bogdan Timofte authored a month ago
119
                }
120
            }
121
        }
122
        .background(
123
            LinearGradient(
124
                colors: [.pink.opacity(0.14), Color.clear],
125
                startPoint: .topLeading,
126
                endPoint: .bottomTrailing
127
            )
128
            .ignoresSafeArea()
129
        )
Bogdan Timofte authored a month ago
130
        .confirmationDialog(
131
            "Stop and discard this session?",
132
            isPresented: $discardConsumptionConfirmation,
133
            titleVisibility: .visible
134
        ) {
135
            Button("Discard", role: .destructive) {
136
                _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: false)
137
            }
138
            Button("Cancel", role: .cancel) {}
139
        } message: {
140
            Text("The current session data will be lost and nothing will be saved.")
141
        }
Bogdan Timofte authored a month ago
142
        .onAppear {
143
            syncDraftSelections()
144
        }
Bogdan Timofte authored a month ago
145
        .onChange(of: selectedChargeTargetID) { _ in
Bogdan Timofte authored a month ago
146
            syncDraftSelections()
147
        }
148
        .onChange(of: openChargeSession?.id) { _ in
149
            syncDraftSelections()
150
        }
Bogdan Timofte authored a month ago
151
    }
152

            
Bogdan Timofte authored a month ago
153
    // MARK: - Computed Properties
154

            
Bogdan Timofte authored a month ago
155
    private var meterMACAddress: String {
156
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
157
    }
158

            
Bogdan Timofte authored a month ago
159
    private var selectedChargedDevice: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
160
        if let openChargeSession {
161
            return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID)
162
        }
163

            
Bogdan Timofte authored a month ago
164
        guard draftChargedPowerbankID == nil else { return nil }
Bogdan Timofte authored a month ago
165
        guard let draftChargedDeviceID else { return nil }
166
        let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID)
167
        return chargedDevice?.isCharger == false ? chargedDevice : nil
Bogdan Timofte authored a month ago
168
    }
169

            
Bogdan Timofte authored a month ago
170
    private var availableChargedDevices: [ChargedDeviceSummary] {
171
        appData.deviceSummaries
172
    }
173

            
Bogdan Timofte authored a month ago
174
    private var selectedChargedPowerbank: PowerbankSummary? {
175
        if let openChargeSession,
176
           let powerbankID = openChargeSession.chargedPowerbankID {
177
            return appData.powerbankSummaries.first { $0.id == powerbankID }
178
        }
179

            
180
        guard let draftChargedPowerbankID else { return nil }
181
        return appData.powerbankSummaries.first { $0.id == draftChargedPowerbankID }
182
    }
183

            
184
    private var selectedChargeTargetID: UUID? {
185
        selectedChargedPowerbank?.id ?? selectedChargedDevice?.id
186
    }
187

            
188
    private var selectedChargeTargetTag: Binding<String> {
Bogdan Timofte authored a month ago
189
        Binding(
Bogdan Timofte authored a month ago
190
            get: {
191
                if let openChargeSession {
192
                    if let powerbankID = openChargeSession.chargedPowerbankID {
193
                        return "powerbank:\(powerbankID.uuidString)"
194
                    }
195
                    return "device:\(openChargeSession.chargedDeviceID.uuidString)"
196
                }
197
                if let draftChargedPowerbankID {
198
                    return "powerbank:\(draftChargedPowerbankID.uuidString)"
199
                }
200
                if let draftChargedDeviceID {
201
                    return "device:\(draftChargedDeviceID.uuidString)"
202
                }
203
                return "none"
204
            },
Bogdan Timofte authored a month ago
205
            set: { newValue in
Bogdan Timofte authored a month ago
206
                if newValue == "none" {
207
                    draftChargedDeviceID = nil
208
                    draftChargedPowerbankID = nil
Bogdan Timofte authored a month ago
209
                    draftChargingTransportMode = nil
210
                    draftChargingStateMode = nil
Bogdan Timofte authored a month ago
211
                } else if newValue.hasPrefix("device:"),
212
                          let uuid = UUID(uuidString: String(newValue.dropFirst("device:".count))) {
213
                    draftChargedDeviceID = uuid
214
                    draftChargedPowerbankID = nil
215
                } else if newValue.hasPrefix("powerbank:"),
216
                          let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
217
                    draftChargedDeviceID = nil
218
                    draftChargedPowerbankID = uuid
Bogdan Timofte authored a month ago
219
                }
Bogdan Timofte authored a month ago
220
            }
221
        )
222
    }
223

            
Bogdan Timofte authored a month ago
224
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
225
        if let openChargeSession,
226
           let chargerID = openChargeSession.chargerID {
227
            return appData.chargedDeviceSummary(id: chargerID)
228
        }
229

            
230
        guard let draftChargerID else { return nil }
231
        let charger = appData.chargedDeviceSummary(id: draftChargerID)
232
        return charger?.isCharger == true ? charger : nil
Bogdan Timofte authored a month ago
233
    }
234

            
Bogdan Timofte authored a month ago
235
    private var availableChargers: [ChargedDeviceSummary] {
236
        appData.chargerSummaries
237
    }
238

            
Bogdan Timofte authored a month ago
239
    private var availablePowerbanks: [PowerbankSummary] {
240
        appData.powerbankSummaries
241
    }
242

            
Bogdan Timofte authored a month ago
243
    private var availableSourcePowerbanks: [PowerbankSummary] {
244
        availablePowerbanks.filter { $0.id != selectedChargedPowerbank?.id }
245
    }
246

            
Bogdan Timofte authored a month ago
247
    private var selectedSourcePowerbank: PowerbankSummary? {
248
        if let openChargeSession,
249
           let powerbankID = openChargeSession.sourcePowerbankID {
250
            return availablePowerbanks.first { $0.id == powerbankID }
251
        }
252
        guard let draftSourcePowerbankID else { return nil }
Bogdan Timofte authored a month ago
253
        return availableSourcePowerbanks.first { $0.id == draftSourcePowerbankID }
Bogdan Timofte authored a month ago
254
    }
255

            
256
    /// Unified source selection encoding — packed into a String tag because SwiftUI Picker
257
    /// works best with hashable primitives. `none`, `charger:UUID`, or `powerbank:UUID`.
258
    private var selectedSourceTag: Binding<String> {
259
        Binding(
260
            get: {
261
                if let openChargeSession {
262
                    if let chargerID = openChargeSession.chargerID { return "charger:\(chargerID.uuidString)" }
263
                    if let powerbankID = openChargeSession.sourcePowerbankID { return "powerbank:\(powerbankID.uuidString)" }
264
                    return "none"
265
                }
266
                if let draftChargerID { return "charger:\(draftChargerID.uuidString)" }
267
                if let draftSourcePowerbankID { return "powerbank:\(draftSourcePowerbankID.uuidString)" }
268
                return "none"
269
            },
270
            set: { newValue in
271
                if newValue == "none" {
272
                    draftChargerID = nil
273
                    draftSourcePowerbankID = nil
274
                } else if newValue.hasPrefix("charger:"),
275
                          let uuid = UUID(uuidString: String(newValue.dropFirst("charger:".count))) {
276
                    draftChargerID = uuid
277
                    draftSourcePowerbankID = nil
278
                } else if newValue.hasPrefix("powerbank:"),
279
                          let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
280
                    draftChargerID = nil
281
                    draftSourcePowerbankID = uuid
282
                }
283
            }
284
        )
285
    }
286

            
287
    private var hasAnySource: Bool {
Bogdan Timofte authored a month ago
288
        availableChargers.isEmpty == false || availableSourcePowerbanks.isEmpty == false
Bogdan Timofte authored a month ago
289
    }
290

            
Bogdan Timofte authored a month ago
291
    private var selectedChargerID: Binding<UUID?> {
292
        Binding(
Bogdan Timofte authored a month ago
293
            get: { openChargeSession?.chargerID ?? draftChargerID },
Bogdan Timofte authored a month ago
294
            set: { newValue in
Bogdan Timofte authored a month ago
295
                draftChargerID = newValue
Bogdan Timofte authored a month ago
296
            }
297
        )
298
    }
299

            
Bogdan Timofte authored a month ago
300
    private var openChargeSession: ChargeSessionSummary? {
301
        appData.activeChargeSessionSummary(for: meterMACAddress)
302
    }
303

            
Bogdan Timofte authored a month ago
304
    private var activeConsumptionSession: ConsumptionMonitorLiveSession? {
305
        appData.consumptionMonitorSession(for: meterMACAddress)
306
    }
307

            
308
    private var draftConsumptionDevice: ChargedDeviceSummary? {
309
        guard let id = draftConsumptionDeviceID else { return nil }
310
        return availableChargedDevices.first { $0.id == id }
311
    }
312

            
Bogdan Timofte authored a month ago
313
    private var showsMeterTotalsCard: Bool {
314
        usbMeter.supportsRecordingView
315
            || usbMeter.supportsDataGroupCommands
316
            || usbMeter.recordedAH > 0
317
            || usbMeter.recordedWH > 0
318
            || usbMeter.recordingDuration > 0
319
    }
320

            
Bogdan Timofte authored a month ago
321
    private var selectedDraftTransportMode: ChargingTransportMode? {
322
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
323
    }
324

            
325
    private var selectedDraftChargingStateMode: ChargingStateMode? {
326
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
327
    }
328

            
Bogdan Timofte authored a month ago
329
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
330
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
331
        let normalized = initialCheckpoint
332
            .trimmingCharacters(in: .whitespacesAndNewlines)
333
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
334
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
335
        return value
336
    }
337

            
Bogdan Timofte authored a month ago
338
    private var hasInitialCheckpointInput: Bool {
339
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
340
    }
341

            
342
    private var shouldRequireInitialCheckpoint: Bool {
343
        initialCheckpointMode == .known
344
    }
345

            
Bogdan Timofte authored a month ago
346
    private var requiresExplicitTransportSelection: Bool {
347
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
348
    }
349

            
350
    private var requiresExplicitChargingStateSelection: Bool {
351
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
352
    }
353

            
Bogdan Timofte authored a month ago
354
    private var startRequirements: [SessionStartRequirement] {
355
        var requirements: [SessionStartRequirement] = []
356

            
357
        if openChargeSession != nil {
358
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
359
        }
360

            
Bogdan Timofte authored a month ago
361
        if selectedChargedPowerbank != nil {
362
            if shouldRequireInitialCheckpoint {
363
                if hasInitialCheckpointInput == false {
364
                    requirements.append(.initialCheckpointEmpty)
365
                } else if initialCheckpointValue == nil {
366
                    requirements.append(.initialCheckpointInvalid)
367
                }
368
            }
369
            return requirements
370
        }
371

            
Bogdan Timofte authored a month ago
372
        guard let selectedChargedDevice else {
373
            requirements.append(.device)
374
            return requirements
Bogdan Timofte authored a month ago
375
        }
376

            
Bogdan Timofte authored a month ago
377
        guard let chargingTransportMode = selectedDraftTransportMode else {
378
            requirements.append(.chargingType)
379
            return requirements
Bogdan Timofte authored a month ago
380
        }
381

            
Bogdan Timofte authored a month ago
382
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
383
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
384
        }
385

            
Bogdan Timofte authored a month ago
386
        guard let chargingStateMode = selectedDraftChargingStateMode else {
387
            requirements.append(.chargingMode)
388
            return requirements
389
        }
390

            
391
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
392
            requirements.append(.chargingMode)
393
        }
394

            
395
        if chargingTransportMode == .wireless, selectedCharger == nil {
396
            requirements.append(.charger)
397
        }
398

            
399
        if shouldRequireInitialCheckpoint {
400
            if hasInitialCheckpointInput == false {
401
                requirements.append(.initialCheckpointEmpty)
402
            } else if initialCheckpointValue == nil {
403
                requirements.append(.initialCheckpointInvalid)
404
            }
405
        }
406

            
407
        return requirements
408
    }
409

            
410
    private var canStartSession: Bool {
411
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
412
    }
413

            
414
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
415
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
416
        return openChargeSession.status.title
417
    }
418

            
419
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
420
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
421
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
422
        case .active:    return .red
423
        case .paused:    return .orange
424
        case .completed: return .green
425
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
426
        }
427
    }
428

            
Bogdan Timofte authored a month ago
429
    private var showsWirelessChargerSection: Bool {
430
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
431
        return transportMode == .wireless
432
    }
Bogdan Timofte authored a month ago
433

            
Bogdan Timofte authored a month ago
434
    /// Source section is visible whenever a transport is picked. For wired sessions only
435
    /// powerbanks are listed (chargers don't apply); for wireless both chargers and powerbanks
436
    /// can be picked.
437
    private var showsSourceSection: Bool {
438
        guard selectedDraftTransportMode != nil || selectedChargedDevice != nil else { return false }
439
        if showsWirelessChargerSection {
440
            return hasAnySource
441
        }
Bogdan Timofte authored a month ago
442
        return availableSourcePowerbanks.isEmpty == false
Bogdan Timofte authored a month ago
443
    }
444

            
445
    private var sourceSectionListsChargers: Bool {
446
        showsWirelessChargerSection
447
    }
448

            
449
    private var sourcePromptText: String {
450
        if showsWirelessChargerSection {
451
            return availableChargers.isEmpty && availablePowerbanks.isEmpty
452
                ? "No source available"
453
                : "Choose source"
454
        }
Bogdan Timofte authored a month ago
455
        return availableSourcePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)"
Bogdan Timofte authored a month ago
456
    }
457

            
Bogdan Timofte authored a month ago
458
    // MARK: - Status Header
459

            
460
    private var statusHeader: some View {
461
        HStack {
462
            Image(systemName: "bolt.fill")
463
                .foregroundColor(.pink)
464
            Text("Charging Session")
465
                .font(.system(.title3, design: .rounded).weight(.bold))
466
            Spacer()
467
            Text(headerStatusTitle)
468
                .font(.caption.weight(.bold))
469
                .foregroundColor(headerStatusColor)
470
                .padding(.horizontal, 10)
471
                .padding(.vertical, 6)
472
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
473
        }
474
        .padding(.horizontal, 18)
475
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
476
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
477
    }
478

            
Bogdan Timofte authored a month ago
479
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
480

            
Bogdan Timofte authored a month ago
481
    private var modePicker: some View {
482
        Picker("", selection: $activeMode) {
483
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
484
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
485
            Label("Consumption", systemImage: "chart.line.uptrend.xyaxis").tag(ActiveMode.consumptionMonitor)
Bogdan Timofte authored a month ago
486
        }
Bogdan Timofte authored a month ago
487
        .pickerStyle(.segmented)
488
        .labelsHidden()
Bogdan Timofte authored a month ago
489
    }
490

            
Bogdan Timofte authored a month ago
491
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
492

            
Bogdan Timofte authored a month ago
493
    private var chargeSessionSetupCard: some View {
494
        VStack(alignment: .leading, spacing: 0) {
495
            // Device
496
            setupRow(icon: "iphone", iconColor: .blue) {
Bogdan Timofte authored a month ago
497
                Picker(selection: selectedChargeTargetTag) {
498
                    Text("Choose target").tag("none")
Bogdan Timofte authored a month ago
499
                    ForEach(availableChargedDevices) { device in
Bogdan Timofte authored a month ago
500
                        Text(device.name).tag("device:\(device.id.uuidString)")
501
                    }
502
                    ForEach(availablePowerbanks) { powerbank in
503
                        Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
Bogdan Timofte authored a month ago
504
                    }
505
                } label: {
506
                    HStack(spacing: 8) {
507
                        if let device = selectedChargedDevice {
508
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
509
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
510
                        } else if let powerbank = selectedChargedPowerbank {
511
                            Label(powerbank.name, systemImage: powerbank.identitySymbolName)
512
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
513
                        } else {
Bogdan Timofte authored a month ago
514
                            Text(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty ? "No targets available" : "Choose target")
Bogdan Timofte authored a month ago
515
                                .foregroundColor(.secondary)
516
                                .font(.subheadline)
517
                        }
518
                        Spacer(minLength: 8)
519
                        Image(systemName: "chevron.up.chevron.down")
520
                            .font(.caption.weight(.semibold))
521
                            .foregroundColor(.secondary)
522
                    }
Bogdan Timofte authored a month ago
523
                }
Bogdan Timofte authored a month ago
524
                .pickerStyle(.menu)
Bogdan Timofte authored a month ago
525
                .disabled(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty)
Bogdan Timofte authored a month ago
526
            }
527

            
Bogdan Timofte authored a month ago
528
            // Charging type — only when device supports multiple
529
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
530
                Divider().padding(.leading, 46)
531
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
532
                    Text("Type")
533
                        .foregroundColor(.secondary)
534
                        .font(.subheadline)
535
                    Spacer()
Bogdan Timofte authored a month ago
536
                    compactSelectionMenu(
537
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
538
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
539
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
540
                                id: mode.id, title: mode.title,
541
                                isSelected: draftChargingTransportMode == mode,
542
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
543
                            )
Bogdan Timofte authored a month ago
544
                        }
Bogdan Timofte authored a month ago
545
                    )
Bogdan Timofte authored a month ago
546
                }
Bogdan Timofte authored a month ago
547
            }
Bogdan Timofte authored a month ago
548

            
Bogdan Timofte authored a month ago
549
            // Source — charger (when wireless) and/or powerbank. None is always allowed.
550
            if showsSourceSection {
Bogdan Timofte authored a month ago
551
                Divider().padding(.leading, 46)
Bogdan Timofte authored a month ago
552
                    .transition(.opacity)
Bogdan Timofte authored a month ago
553
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
Bogdan Timofte authored a month ago
554
                    Picker(selection: selectedSourceTag) {
555
                        Text("None").tag("none")
556
                        if sourceSectionListsChargers {
557
                            ForEach(availableChargers) { charger in
558
                                Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)")
559
                            }
560
                        }
Bogdan Timofte authored a month ago
561
                        ForEach(availableSourcePowerbanks) { powerbank in
Bogdan Timofte authored a month ago
562
                            Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
Bogdan Timofte authored a month ago
563
                        }
564
                    } label: {
565
                        HStack(spacing: 8) {
566
                            if let charger = selectedCharger {
567
                                ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
568
                                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
569
                            } else if let powerbank = selectedSourcePowerbank {
570
                                Label(powerbank.name, systemImage: powerbank.identitySymbolName)
571
                                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
572
                            } else {
Bogdan Timofte authored a month ago
573
                                Text(sourcePromptText)
Bogdan Timofte authored a month ago
574
                                    .foregroundColor(.secondary)
575
                                    .font(.subheadline)
576
                            }
577
                            Spacer(minLength: 8)
578
                            Image(systemName: "chevron.up.chevron.down")
579
                                .font(.caption.weight(.semibold))
580
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
581
                        }
Bogdan Timofte authored a month ago
582
                    }
Bogdan Timofte authored a month ago
583
                    .pickerStyle(.menu)
Bogdan Timofte authored a month ago
584
                }
Bogdan Timofte authored a month ago
585
                .transition(.asymmetric(
586
                    insertion: .move(edge: .top).combined(with: .opacity),
587
                    removal: .opacity
588
                ))
589
            }
590

            
591
            // Charging state — only when device supports multiple
592
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
593
                Divider().padding(.leading, 46)
594
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
595
                    Text("Mode")
596
                        .foregroundColor(.secondary)
597
                        .font(.subheadline)
598
                    Spacer()
599
                    compactSelectionMenu(
600
                        title: draftChargingStateMode?.title ?? "Choose",
601
                        options: device.supportedChargingStateModes.map { mode in
602
                            CompactSelectionOption(
603
                                id: mode.id, title: mode.title,
604
                                isSelected: draftChargingStateMode == mode,
605
                                action: { draftChargingStateMode = mode }
606
                            )
607
                        }
608
                    )
609
                }
Bogdan Timofte authored a month ago
610
            }
611

            
Bogdan Timofte authored a month ago
612
            // Battery checkpoint
613
            Divider().padding(.leading, 46)
614
            setupRow(icon: "battery.75percent", iconColor: .green) {
615
                if initialCheckpointMode == .known {
616
                    Button { adjustInitialCheckpoint(by: -1) } label: {
617
                        Image(systemName: "minus.circle").font(.title3)
618
                    }
619
                    .buttonStyle(.plain)
620

            
621
                    TextField("—", text: $initialCheckpoint)
622
                        .keyboardType(.decimalPad)
623
                        .textFieldStyle(.roundedBorder)
624
                        .frame(width: 52)
625
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
626

            
Bogdan Timofte authored a month ago
627
                    Text("%")
628
                        .font(.subheadline)
629
                        .foregroundColor(.secondary)
630

            
631
                    Button { adjustInitialCheckpoint(by: 1) } label: {
632
                        Image(systemName: "plus.circle").font(.title3)
633
                    }
634
                    .buttonStyle(.plain)
635
                } else {
636
                    Text(initialCheckpointMode == .flat
637
                         ? "Flat (device off / discharged)"
638
                         : "Unknown")
639
                        .font(.subheadline)
640
                        .foregroundColor(.secondary)
641
                }
642
                Spacer()
Bogdan Timofte authored a month ago
643
                compactSelectionMenu(
644
                    title: initialCheckpointMode.title,
645
                    options: InitialCheckpointMode.allCases.map { mode in
646
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
647
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
648
                            isSelected: initialCheckpointMode == mode,
649
                            action: { initialCheckpointMode = mode }
650
                        )
651
                    }
652
                )
Bogdan Timofte authored a month ago
653
            }
654

            
Bogdan Timofte authored a month ago
655
            // Requirement errors
656
            if startRequirements.isEmpty == false {
657
                Divider()
658
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
659
                    ForEach(startRequirements) { requirement in
660
                        Label(requirement.message, systemImage: "exclamationmark.circle")
661
                            .font(.caption)
662
                            .foregroundColor(.orange)
663
                    }
664
                }
Bogdan Timofte authored a month ago
665
                .padding(.horizontal, 14)
666
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
667
            }
Bogdan Timofte authored a month ago
668

            
Bogdan Timofte authored a month ago
669
            // Start button
670
            Divider()
Bogdan Timofte authored a month ago
671
            Button("Start Session") {
672
                startSession()
673
            }
674
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
675
            .padding(.vertical, 11)
676
            .font(.subheadline.weight(.semibold))
677
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
678
            .buttonStyle(.plain)
679
            .disabled(!canStartSession)
680
        }
Bogdan Timofte authored a month ago
681
        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: showsWirelessChargerSection)
Bogdan Timofte authored a month ago
682
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
683
    }
684

            
Bogdan Timofte authored a month ago
685
    // MARK: - Consumption Monitor
686

            
687
    private var consumptionMonitorSetupCard: some View {
688
        VStack(alignment: .leading, spacing: 0) {
689
            setupRow(icon: "iphone", iconColor: .purple) {
690
                Picker(selection: $draftConsumptionDeviceID) {
691
                    Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
692
                        .tag(Optional<UUID>.none)
693
                    ForEach(availableChargedDevices) { device in
694
                        Text(device.name).tag(Optional(device.id))
695
                    }
696
                } label: {
697
                    HStack(spacing: 8) {
698
                        if let device = draftConsumptionDevice {
699
                            ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
700
                                .font(.subheadline.weight(.semibold))
701
                        } else {
702
                            Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device")
703
                                .foregroundColor(.secondary)
704
                                .font(.subheadline)
705
                        }
706
                        Spacer(minLength: 8)
707
                        Image(systemName: "chevron.up.chevron.down")
708
                            .font(.caption.weight(.semibold))
709
                            .foregroundColor(.secondary)
710
                    }
711
                }
712
                .pickerStyle(.menu)
713
                .disabled(availableChargedDevices.isEmpty)
714
            }
715

            
716
            Divider()
717
            Button("Start Session") {
718
                startConsumptionSession()
719
            }
720
            .frame(maxWidth: .infinity)
721
            .padding(.vertical, 11)
722
            .font(.subheadline.weight(.semibold))
723
            .foregroundColor(draftConsumptionDeviceID != nil ? .purple : .secondary)
724
            .buttonStyle(.plain)
725
            .disabled(draftConsumptionDeviceID == nil)
726
        }
727
        .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20)
728
    }
729

            
730
    private func consumptionSessionActiveView(_ session: ConsumptionMonitorLiveSession) -> some View {
731
        ScrollView {
732
            VStack(spacing: 14) {
733
                consumptionSessionHeaderCard
734
                liveMeterStripView
735
                consumptionSessionInfoCard(session)
736
                if session.cumulativeEnergyWh > 0 {
737
                    consumptionProjectionsCard(session)
738
                }
739
            }
740
            .padding()
741
        }
742
    }
743

            
744
    private var consumptionSessionHeaderCard: some View {
745
        HStack {
746
            Image(systemName: "chart.line.uptrend.xyaxis")
747
                .foregroundColor(.purple)
748
            Text("Consumption Monitor")
749
                .font(.system(.title3, design: .rounded).weight(.bold))
750
            Spacer()
751
            Text("Running")
752
                .font(.caption.weight(.bold))
753
                .foregroundColor(.green)
754
                .padding(.horizontal, 10)
755
                .padding(.vertical, 6)
756
                .meterCard(tint: .green, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
757
        }
758
        .padding(.horizontal, 18)
759
        .padding(.vertical, 12)
760
        .meterCard(tint: .purple, fillOpacity: 0.18, strokeOpacity: 0.24)
761
    }
762

            
763
    private func consumptionSessionInfoCard(_ session: ConsumptionMonitorLiveSession) -> some View {
764
        VStack(alignment: .leading, spacing: 0) {
765
            if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
766
                setupRow(icon: "iphone", iconColor: .purple) {
767
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
768
                        .font(.subheadline.weight(.semibold))
769
                    Spacer()
770
                }
771
                Divider().padding(.leading, 46)
772
            }
773

            
774
            setupRow(icon: "clock", iconColor: .secondary) {
775
                Text("Duration")
776
                    .foregroundColor(.secondary)
777
                    .font(.subheadline)
778
                Spacer()
779
                Text(consumptionDurationText(session.elapsedDuration))
780
                    .font(.subheadline.weight(.semibold))
781
                    .monospacedDigit()
782
            }
783

            
784
            Divider().padding(.leading, 46)
785

            
786
            setupRow(icon: "waveform", iconColor: .secondary) {
787
                Text("Samples")
788
                    .foregroundColor(.secondary)
789
                    .font(.subheadline)
790
                Spacer()
791
                Text("\(session.committedSampleCount) × 60 s")
792
                    .font(.subheadline.weight(.semibold))
793
                    .monospacedDigit()
794
            }
795

            
796
            Divider().padding(.leading, 46)
797

            
798
            setupRow(icon: "bolt.fill", iconColor: .yellow) {
799
                Text("Energy")
800
                    .foregroundColor(.secondary)
801
                    .font(.subheadline)
802
                Spacer()
803
                Text(consumptionEnergyText(session.cumulativeEnergyWh))
804
                    .font(.subheadline.weight(.semibold))
805
                    .monospacedDigit()
806
            }
807

            
808
            Divider()
809

            
810
            HStack(spacing: 0) {
811
                Button("Save & Stop") {
812
                    _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: true)
813
                }
814
                .frame(maxWidth: .infinity)
815
                .padding(.vertical, 11)
816
                .font(.subheadline.weight(.semibold))
817
                .foregroundColor(session.committedSampleCount > 0 ? .green : .secondary)
818
                .buttonStyle(.plain)
819
                .disabled(session.committedSampleCount == 0)
820

            
821
                Divider().frame(height: 42)
822

            
823
                Button("Discard") {
824
                    discardConsumptionConfirmation = true
825
                }
826
                .frame(maxWidth: .infinity)
827
                .padding(.vertical, 11)
828
                .font(.subheadline.weight(.semibold))
829
                .foregroundColor(.red)
830
                .buttonStyle(.plain)
831
            }
832
        }
833
        .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20)
834
    }
835

            
836
    private func consumptionProjectionsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
837
        let avgPower = session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001)
838
        return VStack(alignment: .leading, spacing: 0) {
839
            setupRow(icon: "chart.bar.fill", iconColor: .teal) {
840
                Text("Avg Power")
841
                    .foregroundColor(.secondary)
842
                    .font(.subheadline)
843
                Spacer()
844
                Text("\(avgPower.format(decimalDigits: 3)) W")
845
                    .font(.subheadline.weight(.semibold))
846
                    .monospacedDigit()
847
            }
848
            Divider().padding(.leading, 46)
849
            setupRow(icon: "calendar.day.timeline.right", iconColor: .teal) {
850
                Text("24 Hours")
851
                    .foregroundColor(.secondary)
852
                    .font(.subheadline)
853
                Spacer()
854
                Text(consumptionEnergyText(avgPower * 24))
855
                    .font(.subheadline.weight(.semibold))
856
                    .monospacedDigit()
857
            }
858
            Divider().padding(.leading, 46)
859
            setupRow(icon: "calendar", iconColor: .teal) {
860
                Text("30 Days")
861
                    .foregroundColor(.secondary)
862
                    .font(.subheadline)
863
                Spacer()
864
                Text(consumptionEnergyText(avgPower * 24 * 30))
865
                    .font(.subheadline.weight(.semibold))
866
                    .monospacedDigit()
867
            }
868
            Divider().padding(.leading, 46)
869
            setupRow(icon: "calendar", iconColor: .teal) {
870
                Text("1 Year")
871
                    .foregroundColor(.secondary)
872
                    .font(.subheadline)
873
                Spacer()
874
                Text(consumptionEnergyText(avgPower * 24 * 365))
875
                    .font(.subheadline.weight(.semibold))
876
                    .monospacedDigit()
877
            }
878
        }
879
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
880
    }
881

            
Bogdan Timofte authored a month ago
882
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
883

            
Bogdan Timofte authored a month ago
884
    private var standbyPowerCard: some View {
885
        VStack(alignment: .leading, spacing: 12) {
886
            HStack(spacing: 10) {
887
                Image(systemName: "powersleep")
888
                    .foregroundColor(.orange)
889
                    .font(.title3)
890
                VStack(alignment: .leading, spacing: 2) {
891
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
892
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
893
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
894
                        .font(.caption)
895
                        .foregroundColor(.secondary)
896
                }
Bogdan Timofte authored a month ago
897
            }
Bogdan Timofte authored a month ago
898

            
Bogdan Timofte authored a month ago
899
            NavigationLink(
900
                destination: ChargerStandbyPowerWizardView(
901
                    preferredMeterMACAddress: meterMACAddress
902
                )
903
            ) {
904
                HStack {
905
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
906
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
907
                    Text("New Measurement")
Bogdan Timofte authored a month ago
908
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
909
                    Spacer()
910
                    Image(systemName: "chevron.right")
911
                        .font(.caption.weight(.semibold))
912
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
913
                }
Bogdan Timofte authored a month ago
914
                .padding(.vertical, 10)
915
                .padding(.horizontal, 14)
916
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
917
            }
Bogdan Timofte authored a month ago
918
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
919
        }
Bogdan Timofte authored a month ago
920
        .padding(18)
921
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
922
    }
923

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

            
926
    private var liveMeterStripView: some View {
927
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
928
        return LazyVGrid(columns: columns, spacing: 8) {
929
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
930
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
931
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
932
        }
933
    }
934

            
935
    private func metricCell(label: String, value: String, tint: Color) -> some View {
936
        VStack(alignment: .leading, spacing: 3) {
937
            Text(label)
938
                .font(.caption2)
939
                .foregroundColor(.secondary)
940
            Text(value)
941
                .font(.subheadline.weight(.semibold))
942
                .lineLimit(1)
943
                .minimumScaleFactor(0.7)
944
                .monospacedDigit()
945
        }
946
        .frame(maxWidth: .infinity, alignment: .leading)
947
        .padding(.horizontal, 12)
948
        .padding(.vertical, 10)
949
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
950
    }
951

            
Bogdan Timofte authored a month ago
952
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
953
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
954
            HStack(spacing: 8) {
955
                Text("Meter Recorder")
956
                    .font(.headline)
957

            
958
                Spacer(minLength: 0)
959

            
960
                Button {
961
                    showsMeterTotalsInfo.toggle()
962
                } label: {
963
                    Image(systemName: "info.circle")
964
                        .font(.body.weight(.semibold))
965
                        .foregroundColor(.secondary)
966
                }
967
                .buttonStyle(.plain)
968
                .accessibilityLabel("Meter recorder info")
969
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
970
                    VStack(alignment: .leading, spacing: 10) {
971
                        Text("Meter Recorder")
972
                            .font(.headline)
973
                        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.")
974
                            .font(.body)
975
                            .fixedSize(horizontal: false, vertical: true)
976
                    }
977
                    .padding(16)
978
                    .frame(width: 280, alignment: .leading)
979
                }
980
            }
Bogdan Timofte authored a month ago
981

            
982
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
983
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
984
                values: [
985
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
986
                    usbMeter.recordingDurationDescription,
987
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
988
                ]
989
            )
Bogdan Timofte authored a month ago
990

            
991
            if let recordingBootedAt = usbMeter.recordingBootedAt {
992
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
993
                    .font(.caption)
994
                    .foregroundColor(.secondary)
995
            }
Bogdan Timofte authored a month ago
996
        }
997
        .padding(18)
998
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
999
    }
1000

            
Bogdan Timofte authored a month ago
1001
    // MARK: - Helpers
1002

            
1003
    private func setupRow<Content: View>(
1004
        icon: String,
1005
        iconColor: Color = .secondary,
1006
        @ViewBuilder content: () -> Content
1007
    ) -> some View {
1008
        HStack(spacing: 10) {
1009
            Image(systemName: icon)
1010
                .foregroundColor(iconColor)
1011
                .font(.body.weight(.medium))
1012
                .frame(width: 22, alignment: .center)
1013
            content()
1014
        }
1015
        .padding(.horizontal, 14)
1016
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1017
    }
1018

            
1019
    private func startSession() {
Bogdan Timofte authored a month ago
1020
        if let selectedChargedPowerbank {
1021
            let didStart = appData.startPowerbankChargeSession(
1022
                for: usbMeter,
1023
                powerbankID: selectedChargedPowerbank.id,
1024
                sourcePowerbankID: selectedSourcePowerbank?.id,
1025
                initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1026
                startsFromFlatBattery: initialCheckpointMode == .flat
1027
            )
1028

            
1029
            if didStart {
1030
                initialCheckpoint = ""
1031
                initialCheckpointMode = .known
1032
            }
1033
            return
1034
        }
1035

            
Bogdan Timofte authored a month ago
1036
        guard let selectedChargedDevice,
1037
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1038
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1039
            return
1040
        }
1041

            
1042
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
Bogdan Timofte authored a month ago
1043
        let powerbankSourceID = selectedSourcePowerbank?.id
Bogdan Timofte authored a month ago
1044
        let didStart = appData.startChargeSession(
1045
            for: usbMeter,
1046
            chargedDeviceID: selectedChargedDevice.id,
1047
            chargerID: chargerID,
Bogdan Timofte authored a month ago
1048
            sourcePowerbankID: powerbankSourceID,
Bogdan Timofte authored a month ago
1049
            chargingTransportMode: chargingTransportMode,
1050
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1051
            autoStopEnabled: false,
1052
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1053
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1054
        )
Bogdan Timofte authored a month ago
1055

            
1056
        if didStart {
1057
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1058
            initialCheckpointMode = .known
1059
        }
1060
    }
1061

            
Bogdan Timofte authored a month ago
1062
    private func startConsumptionSession() {
1063
        guard let deviceID = draftConsumptionDeviceID else { return }
1064
        _ = appData.startConsumptionMonitor(for: deviceID, on: usbMeter)
1065
    }
1066

            
Bogdan Timofte authored a month ago
1067
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1068
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1069
        let currentValue = initialCheckpointValue ?? 0
1070
        let nextValue = min(max(currentValue + delta, 0), 100)
1071
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1072
    }
1073

            
Bogdan Timofte authored a month ago
1074
    private func syncDraftSelections() {
Bogdan Timofte authored a month ago
1075
        if selectedChargedPowerbank != nil {
1076
            draftChargingTransportMode = .wired
1077
            draftChargingStateMode = .on
1078
            if draftSourcePowerbankID == selectedChargedPowerbank?.id {
1079
                draftSourcePowerbankID = nil
1080
            }
1081
            return
1082
        }
1083

            
Bogdan Timofte authored a month ago
1084
        guard let selectedChargedDevice else {
1085
            draftChargingTransportMode = nil
1086
            draftChargingStateMode = nil
1087
            return
1088
        }
1089

            
1090
        if let openChargeSession {
1091
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1092
            draftChargingStateMode = openChargeSession.chargingStateMode
1093
            return
1094
        }
1095

            
1096
        if let draftChargingTransportMode,
1097
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1098
            self.draftChargingTransportMode = nil
1099
        }
1100

            
1101
        if let draftChargingStateMode,
1102
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1103
            self.draftChargingStateMode = nil
1104
        }
1105

            
1106
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1107
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1108
        }
1109

            
Bogdan Timofte authored a month ago
1110
        if let draftChargingTransportMode {
1111
            draftChargingStateMode = draftChargingStateMode
1112
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1113
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1114
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1115
        }
Bogdan Timofte authored a month ago
1116
    }
Bogdan Timofte authored a month ago
1117

            
1118
    private struct CompactSelectionOption: Identifiable {
1119
        let id: String
1120
        let title: String
1121
        let isSelected: Bool
1122
        let action: () -> Void
1123
    }
1124

            
1125
    private func compactSelectionMenu(
1126
        title: String,
1127
        options: [CompactSelectionOption]
1128
    ) -> some View {
1129
        Menu {
1130
            ForEach(options) { option in
1131
                Button {
1132
                    option.action()
1133
                } label: {
1134
                    if option.isSelected {
1135
                        Label(option.title, systemImage: "checkmark")
1136
                    } else {
1137
                        Text(option.title)
1138
                    }
1139
                }
1140
            }
1141
        } label: {
1142
            HStack(spacing: 8) {
1143
                Text(title)
1144
                    .foregroundColor(.primary)
1145
                Spacer()
1146
                Image(systemName: "chevron.up.chevron.down")
1147
                    .font(.caption.weight(.semibold))
1148
                    .foregroundColor(.secondary)
1149
            }
1150
            .padding(.horizontal, 12)
1151
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1152
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1153
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1154
        }
1155
        .buttonStyle(.plain)
1156
    }
Bogdan Timofte authored a month ago
1157

            
1158
    private func consumptionDurationText(_ duration: TimeInterval) -> String {
1159
        let formatter = DateComponentsFormatter()
1160
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
1161
        formatter.unitsStyle = .abbreviated
1162
        formatter.zeroFormattingBehavior = .pad
1163
        return formatter.string(from: max(duration, 0)) ?? "0m"
1164
    }
1165

            
1166
    private func consumptionEnergyText(_ wattHours: Double) -> String {
1167
        wattHours >= 1000
1168
            ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
1169
            : "\(wattHours.format(decimalDigits: 2)) Wh"
1170
    }
Bogdan Timofte authored a month ago
1171
}