USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1322 lines | 52.14kb
Bogdan Timofte authored a month ago
1
//
2
//  MeterChargeRecordTabView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7

            
8
struct MeterChargeRecordTabView: View {
Bogdan Timofte authored a month ago
9
    var body: some View {
10
        MeterChargeRecordContentView()
11
    }
12
}
13

            
14
struct MeterChargeRecordContentView: View {
Bogdan Timofte authored a month ago
15
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
16
        case known
17
        case unknown
18
        case flat
19

            
20
        var id: String { rawValue }
21

            
22
        var title: String {
23
            switch self {
24
            case .known:
25
                return "Known"
26
            case .unknown:
27
                return "Unknown"
28
            case .flat:
29
                return "Flat"
30
            }
31
        }
32
    }
33

            
Bogdan Timofte authored a month ago
34
    @EnvironmentObject private var appData: AppData
35
    @EnvironmentObject private var usbMeter: Meter
Bogdan Timofte authored a month ago
36

            
Bogdan Timofte authored a month ago
37
    @State private var chargedDeviceLibraryVisibility = false
38
    @State private var chargerLibraryVisibility = false
39
    @State private var editingChargedDevice: ChargedDeviceSummary?
40
    @State private var targetNotificationEditorVisibility = false
Bogdan Timofte authored a month ago
41
    @State private var pendingStopRequest: ChargeSessionStopRequest?
Bogdan Timofte authored a month ago
42
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
Bogdan Timofte authored a month ago
43
    @State private var draftChargingTransportMode: ChargingTransportMode?
44
    @State private var draftChargingStateMode: ChargingStateMode?
Bogdan Timofte authored a month ago
45
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
Bogdan Timofte authored a month ago
46
    @State private var initialCheckpoint = ""
Bogdan Timofte authored a month ago
47
    @State private var showsMeterTotalsInfo = false
Bogdan Timofte authored a month ago
48
    @State private var showsInlineCheckpointEditor = false
Bogdan Timofte authored a month ago
49

            
50
    private enum SessionStartRequirement: Identifiable {
51
        case existingSession
52
        case device
53
        case chargingType
54
        case chargingMode
55
        case charger
56
        case initialCheckpointEmpty
57
        case initialCheckpointInvalid
58

            
59
        var id: String {
60
            switch self {
61
            case .existingSession:
62
                return "existing-session"
63
            case .device:
64
                return "device"
65
            case .chargingType:
66
                return "charging-type"
67
            case .chargingMode:
68
                return "charging-mode"
69
            case .charger:
70
                return "charger"
71
            case .initialCheckpointEmpty:
72
                return "initial-checkpoint-empty"
73
            case .initialCheckpointInvalid:
74
                return "initial-checkpoint-invalid"
75
            }
76
        }
77

            
78
        var message: String {
79
            switch self {
80
            case .existingSession:
81
                return "Stop or pause the current session before starting another one."
82
            case .device:
83
                return "Select the device that is charging."
84
            case .chargingType:
85
                return "Choose the charging type for this session."
86
            case .chargingMode:
87
                return "Choose whether the device is on or off for this session."
88
            case .charger:
89
                return "Select the wireless charger used in this session."
90
            case .initialCheckpointEmpty:
91
                return "Enter the initial battery percentage."
92
            case .initialCheckpointInvalid:
93
                return "Initial battery percentage must be between 0 and 100."
94
            }
95
        }
96
    }
Bogdan Timofte authored a month ago
97

            
98
    var body: some View {
99
        ScrollView {
100
            VStack(spacing: 16) {
Bogdan Timofte authored a month ago
101
                headerCard
102
                sessionSetupCard
Bogdan Timofte authored a month ago
103

            
Bogdan Timofte authored a month ago
104
                if let openChargeSession {
105
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
106

            
Bogdan Timofte authored a month ago
107
                    if showsMeterTotalsCard {
108
                        meterTotalsCard
109
                    }
110

            
Bogdan Timofte authored a month ago
111
                    if let sessionChartTimeRange {
112
                        sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession)
Bogdan Timofte authored a month ago
113
                    }
Bogdan Timofte authored a month ago
114
                } else if showsMeterTotalsCard {
Bogdan Timofte authored a month ago
115
                    meterTotalsCard
Bogdan Timofte authored a month ago
116
                }
117
            }
118
            .padding()
119
        }
120
        .background(
121
            LinearGradient(
122
                colors: [.pink.opacity(0.14), Color.clear],
123
                startPoint: .topLeading,
124
                endPoint: .bottomTrailing
125
            )
126
            .ignoresSafeArea()
127
        )
128
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
129
            ChargedDeviceLibrarySheetView(
130
                visibility: $chargedDeviceLibraryVisibility,
Bogdan Timofte authored a month ago
131
                meterMACAddress: meterMACAddress,
Bogdan Timofte authored a month ago
132
                meterTint: usbMeter.color,
133
                mode: .device
134
            )
135
            .environmentObject(appData)
136
        }
137
        .sheet(isPresented: $chargerLibraryVisibility) {
138
            ChargedDeviceLibrarySheetView(
139
                visibility: $chargerLibraryVisibility,
Bogdan Timofte authored a month ago
140
                meterMACAddress: meterMACAddress,
Bogdan Timofte authored a month ago
141
                meterTint: usbMeter.color,
142
                mode: .charger
143
            )
144
            .environmentObject(appData)
145
        }
146
        .sheet(item: $editingChargedDevice) { chargedDevice in
147
            ChargedDeviceEditorSheetView(
148
                meterMACAddress: nil,
Bogdan Timofte authored a month ago
149
                kind: chargedDevice.kind,
Bogdan Timofte authored a month ago
150
                chargedDevice: chargedDevice
151
            )
152
            .environmentObject(appData)
153
        }
154
        .sheet(isPresented: $targetNotificationEditorVisibility) {
Bogdan Timofte authored a month ago
155
            if let openChargeSession {
Bogdan Timofte authored a month ago
156
                BatteryTargetNotificationEditorSheetView(
Bogdan Timofte authored a month ago
157
                    sessionID: openChargeSession.id,
158
                    initialTargetPercent: openChargeSession.targetBatteryPercent
Bogdan Timofte authored a month ago
159
                )
160
                .environmentObject(appData)
161
            }
162
        }
Bogdan Timofte authored a month ago
163
        .sheet(item: $pendingStopRequest) { request in
164
            ChargeSessionCompletionSheetView(
165
                sessionID: request.sessionID,
166
                title: request.title,
167
                confirmTitle: request.confirmTitle,
168
                explanation: request.explanation
169
            )
170
            .environmentObject(appData)
171
        }
Bogdan Timofte authored a month ago
172
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
173
            Alert(
174
                title: Text("Delete Battery Checkpoint"),
175
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
176
                primaryButton: .destructive(Text("Delete")) {
177
                    if let openChargeSession {
178
                        _ = appData.deleteBatteryCheckpoint(
179
                            checkpointID: checkpoint.id,
180
                            for: openChargeSession.id
181
                        )
182
                    }
183
                },
184
                secondaryButton: .cancel()
185
            )
186
        }
Bogdan Timofte authored a month ago
187
        .onAppear {
188
            syncDraftSelections()
189
        }
190
        .onChange(of: selectedChargedDevice?.id) { _ in
191
            syncDraftSelections()
192
        }
193
        .onChange(of: openChargeSession?.id) { _ in
194
            syncDraftSelections()
Bogdan Timofte authored a month ago
195
            showsInlineCheckpointEditor = false
Bogdan Timofte authored a month ago
196
        }
Bogdan Timofte authored a month ago
197
    }
198

            
Bogdan Timofte authored a month ago
199
    private var meterMACAddress: String {
200
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
201
    }
202

            
Bogdan Timofte authored a month ago
203
    private var selectedChargedDevice: ChargedDeviceSummary? {
204
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
205
    }
206

            
207
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
208
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
209
    }
210

            
Bogdan Timofte authored a month ago
211
    private var openChargeSession: ChargeSessionSummary? {
212
        appData.activeChargeSessionSummary(for: meterMACAddress)
213
    }
214

            
Bogdan Timofte authored a month ago
215
    private var showsMeterTotalsCard: Bool {
216
        usbMeter.supportsRecordingView
217
            || usbMeter.supportsDataGroupCommands
218
            || usbMeter.recordedAH > 0
219
            || usbMeter.recordedWH > 0
220
            || usbMeter.recordingDuration > 0
221
    }
222

            
Bogdan Timofte authored a month ago
223
    private var selectedDraftTransportMode: ChargingTransportMode? {
224
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
225
    }
226

            
227
    private var selectedDraftChargingStateMode: ChargingStateMode? {
228
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
229
    }
230

            
Bogdan Timofte authored a month ago
231
    private var initialCheckpointValue: Double? {
232
        guard initialCheckpointMode == .known else {
Bogdan Timofte authored a month ago
233
            return nil
234
        }
235
        let normalized = initialCheckpoint
236
            .trimmingCharacters(in: .whitespacesAndNewlines)
237
            .replacingOccurrences(of: ",", with: ".")
238
        guard let value = Double(normalized), value >= 0, value <= 100 else {
239
            return nil
240
        }
241
        return value
242
    }
243

            
Bogdan Timofte authored a month ago
244
    private var hasInitialCheckpointInput: Bool {
245
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
246
    }
247

            
248
    private var shouldRequireInitialCheckpoint: Bool {
249
        initialCheckpointMode == .known
250
    }
251

            
Bogdan Timofte authored a month ago
252
    private var requiresExplicitTransportSelection: Bool {
253
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
254
    }
255

            
256
    private var requiresExplicitChargingStateSelection: Bool {
257
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
258
    }
259

            
Bogdan Timofte authored a month ago
260
    private var startRequirements: [SessionStartRequirement] {
261
        var requirements: [SessionStartRequirement] = []
262

            
263
        if openChargeSession != nil {
264
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
265
        }
266

            
Bogdan Timofte authored a month ago
267
        guard let selectedChargedDevice else {
268
            requirements.append(.device)
269
            return requirements
Bogdan Timofte authored a month ago
270
        }
271

            
Bogdan Timofte authored a month ago
272
        guard let chargingTransportMode = selectedDraftTransportMode else {
273
            requirements.append(.chargingType)
274
            return requirements
Bogdan Timofte authored a month ago
275
        }
276

            
Bogdan Timofte authored a month ago
277
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
278
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
279
        }
280

            
Bogdan Timofte authored a month ago
281
        guard let chargingStateMode = selectedDraftChargingStateMode else {
282
            requirements.append(.chargingMode)
283
            return requirements
284
        }
285

            
286
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
287
            requirements.append(.chargingMode)
288
        }
289

            
290
        if chargingTransportMode == .wireless, selectedCharger == nil {
291
            requirements.append(.charger)
292
        }
293

            
294
        if shouldRequireInitialCheckpoint {
295
            if hasInitialCheckpointInput == false {
296
                requirements.append(.initialCheckpointEmpty)
297
            } else if initialCheckpointValue == nil {
298
                requirements.append(.initialCheckpointInvalid)
299
            }
300
        }
301

            
302
        return requirements
303
    }
304

            
305
    private var canStartSession: Bool {
306
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
307
    }
308

            
309
    private var headerStatusTitle: String {
310
        guard let openChargeSession else {
311
            return "Idle"
312
        }
313
        return openChargeSession.status.title
314
    }
315

            
316
    private var headerStatusColor: Color {
317
        guard let openChargeSession else {
318
            return .secondary
319
        }
320

            
321
        switch openChargeSession.status {
322
        case .active:
323
            return .red
324
        case .paused:
325
            return .orange
326
        case .completed:
327
            return .green
328
        case .abandoned:
329
            return .secondary
330
        }
331
    }
332

            
333
    private var sessionChartTimeRange: ClosedRange<Date>? {
334
        guard let openChargeSession else {
335
            return nil
336
        }
337

            
338
        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
339
        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
340
    }
341

            
342
    private var headerCard: some View {
343
        VStack(alignment: .leading, spacing: 8) {
344
            HStack {
345
                Text("Charging Session")
346
                    .font(.system(.title3, design: .rounded).weight(.bold))
Bogdan Timofte authored a month ago
347
                ContextInfoButton(
348
                    title: "Charging Session",
349
                    message: "Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit."
350
                )
Bogdan Timofte authored a month ago
351
                Spacer()
352
                Text(headerStatusTitle)
353
                    .font(.caption.weight(.bold))
354
                    .foregroundColor(headerStatusColor)
355
                    .padding(.horizontal, 10)
356
                    .padding(.vertical, 6)
357
                    .meterCard(
358
                        tint: headerStatusColor,
359
                        fillOpacity: 0.18,
360
                        strokeOpacity: 0.24,
361
                        cornerRadius: 999
362
                    )
363
            }
364

            
365
        }
366
        .frame(maxWidth: .infinity)
367
        .padding(18)
368
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
369
    }
370

            
371
    private var sessionSetupCard: some View {
372
        VStack(alignment: .leading, spacing: 14) {
Bogdan Timofte authored a month ago
373
            HStack {
Bogdan Timofte authored a month ago
374
                Text(openChargeSession == nil ? "Session Setup" : "Session Context")
Bogdan Timofte authored a month ago
375
                    .font(.headline)
Bogdan Timofte authored a month ago
376
                ContextInfoButton(
377
                    title: openChargeSession == nil ? "Session Setup" : "Session Context",
378
                    message: "Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection."
379
                )
Bogdan Timofte authored a month ago
380
                Spacer()
381
                Button("Library") {
382
                    chargedDeviceLibraryVisibility = true
383
                }
Bogdan Timofte authored a month ago
384
                .disabled(openChargeSession != nil)
Bogdan Timofte authored a month ago
385
            }
386

            
387
            if let selectedChargedDevice {
Bogdan Timofte authored a month ago
388
                deviceSummary(selectedChargedDevice)
Bogdan Timofte authored a month ago
389

            
Bogdan Timofte authored a month ago
390
                if openChargeSession == nil {
391
                    setupControls(for: selectedChargedDevice)
392
                }
Bogdan Timofte authored a month ago
393

            
Bogdan Timofte authored a month ago
394
                Button("Edit Device") {
395
                    editingChargedDevice = selectedChargedDevice
396
                }
397
                .frame(maxWidth: .infinity)
398
                .padding(.vertical, 10)
399
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
400
                .buttonStyle(.plain)
401
            }
Bogdan Timofte authored a month ago
402

            
403
            if selectedChargedDevice != nil {
404
                Divider()
405
            }
406
            standbyMeasurementSection
Bogdan Timofte authored a month ago
407
        }
408
        .padding(18)
409
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
410
    }
411

            
412
    private func deviceSummary(_ chargedDevice: ChargedDeviceSummary) -> some View {
413
        VStack(alignment: .leading, spacing: 12) {
414
            HStack(alignment: .top, spacing: 14) {
415
                ChargedDeviceQRCodeView(
416
                    qrIdentifier: chargedDevice.qrIdentifier,
417
                    side: 88
418
                )
419

            
420
                VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
421
                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
422
                        .font(.headline)
423

            
Bogdan Timofte authored a month ago
424
                    Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
425
                        .font(.caption.weight(.semibold))
426
                        .foregroundColor(.secondary)
427

            
428
                    Text(chargedDevice.chargingStateAvailability.description)
429
                        .font(.caption2)
430
                        .foregroundColor(.secondary)
431

            
432
                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
433
                        .font(.caption2)
434
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
435

            
Bogdan Timofte authored a month ago
436
                    if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh {
437
                        Text("Estimated capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
438
                            .font(.caption2)
439
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
440
                    }
441
                }
Bogdan Timofte authored a month ago
442

            
Bogdan Timofte authored a month ago
443
                Spacer(minLength: 0)
444
            }
Bogdan Timofte authored a month ago
445

            
Bogdan Timofte authored a month ago
446
            if showsWirelessChargerSection {
447
                Divider()
448
                wirelessChargerSection
449
            }
450
        }
451
    }
452

            
453
    private func setupControls(for chargedDevice: ChargedDeviceSummary) -> some View {
454
        VStack(alignment: .leading, spacing: 12) {
455
            if requiresExplicitTransportSelection {
456
                VStack(alignment: .leading, spacing: 8) {
457
                    Text("Charging Type")
458
                        .font(.subheadline.weight(.semibold))
459

            
Bogdan Timofte authored a month ago
460
                    compactSelectionMenu(
461
                        title: draftChargingTransportMode?.title ?? "Choose",
462
                        options: chargedDevice.supportedChargingModes.map { chargingTransportMode in
463
                            CompactSelectionOption(
464
                                id: chargingTransportMode.id,
465
                                title: chargingTransportMode.title,
466
                                isSelected: draftChargingTransportMode == chargingTransportMode,
467
                                action: { draftChargingTransportMode = chargingTransportMode }
468
                            )
Bogdan Timofte authored a month ago
469
                        }
Bogdan Timofte authored a month ago
470
                    )
Bogdan Timofte authored a month ago
471

            
Bogdan Timofte authored a month ago
472
                    if draftChargingTransportMode == nil {
473
                        Text("Pick the charging type explicitly before starting.")
474
                            .font(.caption2)
475
                            .foregroundColor(.orange)
476
                    }
Bogdan Timofte authored a month ago
477
                }
Bogdan Timofte authored a month ago
478
            } else if let chargingTransportMode = chargedDevice.supportedChargingModes.first {
479
                Label(
480
                    "Charging type: \(chargingTransportMode.title)",
481
                    systemImage: chargingTransportMode.symbolName
482
                )
483
                .font(.subheadline.weight(.semibold))
484
            }
Bogdan Timofte authored a month ago
485

            
Bogdan Timofte authored a month ago
486
            if requiresExplicitChargingStateSelection {
487
                VStack(alignment: .leading, spacing: 8) {
488
                    Text("Charging Mode")
489
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
490

            
Bogdan Timofte authored a month ago
491
                    compactSelectionMenu(
492
                        title: draftChargingStateMode?.title ?? "Choose",
493
                        options: chargedDevice.supportedChargingStateModes.map { chargingStateMode in
494
                            CompactSelectionOption(
495
                                id: chargingStateMode.id,
496
                                title: chargingStateMode.title,
497
                                isSelected: draftChargingStateMode == chargingStateMode,
498
                                action: { draftChargingStateMode = chargingStateMode }
499
                            )
Bogdan Timofte authored a month ago
500
                        }
Bogdan Timofte authored a month ago
501
                    )
Bogdan Timofte authored a month ago
502

            
Bogdan Timofte authored a month ago
503
                    if draftChargingStateMode == nil {
504
                        Text("Pick whether the device is on or off for this session.")
505
                            .font(.caption2)
506
                            .foregroundColor(.orange)
Bogdan Timofte authored a month ago
507
                    }
508
                }
Bogdan Timofte authored a month ago
509
            } else if let chargingStateMode = chargedDevice.supportedChargingStateModes.first {
510
                Label(
511
                    "Charging mode: \(chargingStateMode.title)",
512
                    systemImage: chargingStateMode == .off ? "power.circle" : "power"
513
                )
514
                .font(.subheadline.weight(.semibold))
515
            }
516

            
517
            VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
518
                HStack(spacing: 8) {
519
                    Text("Initial Checkpoint")
520
                        .font(.subheadline.weight(.semibold))
521
                    ContextInfoButton(
522
                        title: "Initial Checkpoint",
523
                        message: "Use the battery level shown by the device right now when it is known. A known checkpoint improves battery prediction and capacity learning, but the session can also start without one when the level is unavailable."
524
                    )
525
                }
Bogdan Timofte authored a month ago
526

            
Bogdan Timofte authored a month ago
527
                compactSelectionMenu(
528
                    title: initialCheckpointMode.title,
529
                    options: InitialCheckpointMode.allCases.map { mode in
530
                        CompactSelectionOption(
531
                            id: mode.id,
532
                            title: mode.title,
533
                            isSelected: initialCheckpointMode == mode,
534
                            action: { initialCheckpointMode = mode }
535
                        )
536
                    }
537
                )
538

            
539
                if initialCheckpointMode == .known {
540
                    HStack(spacing: 10) {
541
                        Button {
542
                            adjustInitialCheckpoint(by: -1)
543
                        } label: {
544
                            Image(systemName: "minus.circle")
545
                                .font(.title3)
546
                        }
547
                        .buttonStyle(.plain)
548

            
549
                        TextField("Battery %", text: $initialCheckpoint)
550
                            .keyboardType(.decimalPad)
551
                            .textFieldStyle(.roundedBorder)
552
                            .frame(width: 92)
553

            
554
                        Text("%")
555
                            .font(.subheadline.weight(.semibold))
556
                            .foregroundColor(.secondary)
557

            
558
                        Button {
559
                            adjustInitialCheckpoint(by: 1)
560
                        } label: {
561
                            Image(systemName: "plus.circle")
562
                                .font(.title3)
563
                        }
564
                        .buttonStyle(.plain)
565

            
566
                        Spacer()
567
                    }
568
                } else {
569
                    Text(
570
                        initialCheckpointMode == .flat
571
                        ? "Use Flat when the device does not turn on yet. Predictions and capacity estimates stay off until you record a positive battery level."
572
                        : "Start without an initial battery checkpoint only when the level cannot be read reliably, for example on a device without display."
573
                    )
574
                        .font(.caption2)
575
                        .foregroundColor(.orange)
576
                }
Bogdan Timofte authored a month ago
577

            
Bogdan Timofte authored a month ago
578
            }
579

            
Bogdan Timofte authored a month ago
580
            VStack(alignment: .leading, spacing: 8) {
581
                Text("Start Requirements")
582
                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
583

            
Bogdan Timofte authored a month ago
584
                if startRequirements.isEmpty {
585
                    Label("Everything needed to start is ready.", systemImage: "checkmark.circle.fill")
586
                        .font(.caption)
587
                        .foregroundColor(.green)
588
                } else {
589
                    ForEach(startRequirements) { requirement in
590
                        Label(requirement.message, systemImage: "exclamationmark.circle")
591
                            .font(.caption)
592
                            .foregroundColor(.orange)
593
                    }
594
                }
595
            }
Bogdan Timofte authored a month ago
596

            
597
            Button("Start Session") {
598
                startSession()
599
            }
600
            .frame(maxWidth: .infinity)
601
            .padding(.vertical, 10)
602
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
603
            .buttonStyle(.plain)
604
            .disabled(!canStartSession)
605
        }
606
    }
607

            
608
    private var wirelessChargerSection: some View {
609
        VStack(alignment: .leading, spacing: 10) {
610
            HStack {
611
                Text("Wireless Charger")
612
                    .font(.subheadline.weight(.semibold))
613
                Spacer()
614
                Button(selectedCharger == nil ? "Select" : "Change") {
615
                    chargerLibraryVisibility = true
Bogdan Timofte authored a month ago
616
                }
Bogdan Timofte authored a month ago
617
                .disabled(openChargeSession != nil)
618
            }
Bogdan Timofte authored a month ago
619

            
Bogdan Timofte authored a month ago
620
            if let selectedCharger {
621
                HStack(alignment: .top, spacing: 12) {
622
                    ChargedDeviceQRCodeView(
623
                        qrIdentifier: selectedCharger.qrIdentifier,
624
                        side: 62
625
                    )
626

            
627
                    VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
628
                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
Bogdan Timofte authored a month ago
629
                            .font(.subheadline.weight(.semibold))
630

            
631
                        if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
632
                            Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
633
                                .font(.caption)
634
                                .foregroundColor(.secondary)
635
                        }
636

            
637
                        if let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps {
638
                            Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A")
639
                                .font(.caption2)
640
                                .foregroundColor(.secondary)
641
                        } else {
642
                            Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.")
643
                                .font(.caption2)
644
                                .foregroundColor(.orange)
645
                        }
646
                    }
Bogdan Timofte authored a month ago
647
                }
Bogdan Timofte authored a month ago
648

            
Bogdan Timofte authored a month ago
649
            } else {
Bogdan Timofte authored a month ago
650
                Text("Wireless sessions need a selected charger in addition to the charged device.")
651
                    .font(.caption)
Bogdan Timofte authored a month ago
652
                    .foregroundColor(.secondary)
653
            }
654
        }
655
    }
656

            
Bogdan Timofte authored a month ago
657
    private var standbyMeasurementSection: some View {
658
        VStack(alignment: .leading, spacing: 10) {
659
            HStack {
660
                Text("Charger Standby Power")
661
                    .font(.subheadline.weight(.semibold))
662
                Spacer()
663
                Button(selectedCharger == nil ? "Select Charger" : "Change Charger") {
664
                    chargerLibraryVisibility = true
665
                }
666
                .disabled(openChargeSession != nil)
667
            }
668

            
669
            if let selectedCharger {
670
                HStack(alignment: .top, spacing: 12) {
671
                    ChargedDeviceQRCodeView(
672
                        qrIdentifier: selectedCharger.qrIdentifier,
673
                        side: 62
674
                    )
675

            
676
                    VStack(alignment: .leading, spacing: 6) {
677
                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
678
                            .font(.subheadline.weight(.semibold))
679

            
680
                        Text(
681
                            selectedCharger.latestStandbyPowerMeasurement.map {
682
                                "Latest standby: \($0.averagePowerWatts.format(decimalDigits: 3)) W"
683
                            } ?? "No standby baseline saved yet."
684
                        )
685
                        .font(.caption)
686
                        .foregroundColor(.secondary)
687
                    }
688
                }
689

            
690
                NavigationLink(
691
                    destination: ChargerStandbyPowerWizardView(
692
                        preferredMeterMACAddress: meterMACAddress,
693
                        preferredChargerID: selectedCharger.id
694
                    )
695
                ) {
696
                    Label("New Measurement", systemImage: "plus.circle.fill")
697
                        .font(.subheadline.weight(.semibold))
698
                        .foregroundColor(.orange)
699
                }
700
                .buttonStyle(.plain)
701
                .disabled(openChargeSession != nil)
702

            
703
                if openChargeSession != nil {
704
                    Text("Stop or pause the active charge session before starting a standby-power run on this meter.")
705
                        .font(.caption)
706
                        .foregroundColor(.secondary)
707
                }
708
            } else {
709
                NavigationLink(
710
                    destination: ChargerStandbyPowerWizardView(
711
                        preferredMeterMACAddress: meterMACAddress
712
                    )
713
                ) {
714
                    Label("New Measurement", systemImage: "plus.circle.fill")
715
                        .font(.subheadline.weight(.semibold))
716
                        .foregroundColor(.orange)
717
                }
718
                .buttonStyle(.plain)
719

            
720
                Text("Open the wizard and choose the charger there, or preselect one from Charge Record first.")
721
                    .font(.caption)
722
                    .foregroundColor(.secondary)
723
            }
724
        }
725
    }
726

            
Bogdan Timofte authored a month ago
727
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
728
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
Bogdan Timofte authored a month ago
729
        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
Bogdan Timofte authored a month ago
730
        return VStack(alignment: .leading, spacing: 12) {
731
            HStack(spacing: 8) {
732
                Text("Charging Monitor")
733
                    .font(.headline)
734
                ContextInfoButton(
735
                    title: "Charging Monitor",
736
                    message: "The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own."
737
                )
738
            }
Bogdan Timofte authored a month ago
739

            
740
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
741
                labels: ["Type", "Mode", "Energy", "Duration", "Auto Stop"],
Bogdan Timofte authored a month ago
742
                values: [
Bogdan Timofte authored a month ago
743
                    openChargeSession.chargingTransportMode.title,
744
                    openChargeSession.chargingStateMode.title,
Bogdan Timofte authored a month ago
745
                    "\(displayedEnergyWh.format(decimalDigits: 3)) Wh",
Bogdan Timofte authored a month ago
746
                    formatDuration(max(openChargeSession.effectiveDuration, 0)),
Bogdan Timofte authored a month ago
747
                    autoStopLabel(for: openChargeSession)
Bogdan Timofte authored a month ago
748
                ]
749
            )
750

            
Bogdan Timofte authored a month ago
751
            if openChargeSession.stopThresholdAmps > 0 {
752
                Text("Meter monitor threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
753
                    .font(.caption)
754
                    .foregroundColor(.secondary)
755
            }
756

            
757
            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
758
                for: openChargeSession,
759
                effectiveEnergyWhOverride: displayedEnergyWh
760
            ) {
Bogdan Timofte authored a month ago
761
                VStack(alignment: .leading, spacing: 4) {
762
                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
763
                        .font(.caption.weight(.semibold))
764
                    Text(
765
                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
766
                    )
767
                    .font(.caption2)
768
                    .foregroundColor(.secondary)
769
                }
770
            }
771

            
Bogdan Timofte authored a month ago
772
            if let sessionWarning = sessionWarning(for: openChargeSession) {
773
                Text(sessionWarning)
774
                    .font(.caption)
775
                    .foregroundColor(.orange)
776
            }
777

            
778
            if openChargeSession.isPaused {
779
                Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.")
780
                    .font(.caption)
781
                    .foregroundColor(.secondary)
782
            }
783

            
784
            if openChargeSession.requiresCompletionConfirmation {
785
                completionConfirmationCard(openChargeSession)
786
            }
787

            
788
            if let targetBatteryPercent = openChargeSession.targetBatteryPercent {
Bogdan Timofte authored a month ago
789
                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
790
                    .font(.caption.weight(.semibold))
791
            } else {
792
                Text("No target battery notification configured.")
793
                    .font(.caption)
794
                    .foregroundColor(.secondary)
795
            }
796

            
Bogdan Timofte authored a month ago
797
            if !openChargeSession.checkpoints.isEmpty {
798
                checkpointList(
799
                    checkpoints: Array(openChargeSession.checkpoints.suffix(6).reversed())
800
                )
801
            }
802

            
803
            Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
804
                showsInlineCheckpointEditor.toggle()
Bogdan Timofte authored a month ago
805
            }
806
            .frame(maxWidth: .infinity)
807
            .padding(.vertical, 10)
808
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
809
            .buttonStyle(.plain)
810

            
Bogdan Timofte authored a month ago
811
            if showsInlineCheckpointEditor {
812
                BatteryCheckpointEditorContentView(
813
                    sessionID: openChargeSession.id,
814
                    message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
815
                    effectiveEnergyWhOverride: displayedEnergyWh,
816
                    measuredChargeAhOverride: displayedChargeAh,
817
                    onCancel: { showsInlineCheckpointEditor = false },
818
                    onSaved: { showsInlineCheckpointEditor = false }
819
                )
820
            }
821

            
Bogdan Timofte authored a month ago
822
            Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
Bogdan Timofte authored a month ago
823
                targetNotificationEditorVisibility = true
824
            }
825
            .frame(maxWidth: .infinity)
826
            .padding(.vertical, 10)
827
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
828
            .buttonStyle(.plain)
829

            
Bogdan Timofte authored a month ago
830
            if openChargeSession.targetBatteryPercent != nil {
Bogdan Timofte authored a month ago
831
                Button("Clear Target Notification") {
Bogdan Timofte authored a month ago
832
                    _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
Bogdan Timofte authored a month ago
833
                }
834
                .frame(maxWidth: .infinity)
835
                .padding(.vertical, 10)
836
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
837
                .buttonStyle(.plain)
838
            }
839

            
Bogdan Timofte authored a month ago
840
            if openChargeSession.status == .active {
841
                Button("Pause Session") {
842
                    _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
843
                }
844
                .frame(maxWidth: .infinity)
845
                .padding(.vertical, 10)
846
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
847
                .buttonStyle(.plain)
848
            } else if openChargeSession.status == .paused {
849
                Button("Resume Session") {
850
                    _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
851
                }
852
                .frame(maxWidth: .infinity)
853
                .padding(.vertical, 10)
854
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
855
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
856
            }
857

            
Bogdan Timofte authored a month ago
858
            Button("Stop Session") {
859
                pendingStopRequest = ChargeSessionStopRequest(
860
                    sessionID: openChargeSession.id,
861
                    title: "Stop Session",
862
                    confirmTitle: "Stop",
863
                    explanation: "Record the final battery checkpoint before closing this session."
864
                )
Bogdan Timofte authored a month ago
865
            }
Bogdan Timofte authored a month ago
866
            .frame(maxWidth: .infinity)
867
            .padding(.vertical, 10)
868
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
869
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
870

            
871
        }
872
        .padding(18)
873
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
874
    }
875

            
Bogdan Timofte authored a month ago
876
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
877
        VStack(alignment: .leading, spacing: 10) {
878
            Text("Completion Needs Confirmation")
879
                .font(.subheadline.weight(.semibold))
880

            
Bogdan Timofte authored a month ago
881
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
882
                Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
Bogdan Timofte authored a month ago
883
                    .font(.caption)
884
                    .foregroundColor(.secondary)
885
            } else {
Bogdan Timofte authored a month ago
886
                Text("Current dropped to the learned stop threshold, but the battery prediction does not look like a normal finish yet.")
Bogdan Timofte authored a month ago
887
                    .font(.caption)
888
                    .foregroundColor(.secondary)
889
            }
890

            
Bogdan Timofte authored a month ago
891
            Button("Finish Session With Final Checkpoint") {
892
                pendingStopRequest = ChargeSessionStopRequest(
893
                    sessionID: openChargeSession.id,
894
                    title: "Finish Session",
895
                    confirmTitle: "Finish",
896
                    explanation: "Add the final checkpoint before confirming the stop."
897
                )
Bogdan Timofte authored a month ago
898
            }
899
            .frame(maxWidth: .infinity)
900
            .padding(.vertical, 10)
901
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
902
            .buttonStyle(.plain)
903

            
904
            Button("Keep Monitoring") {
Bogdan Timofte authored a month ago
905
                _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
906
            }
907
            .frame(maxWidth: .infinity)
908
            .padding(.vertical, 10)
909
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
910
            .buttonStyle(.plain)
911
        }
912
        .padding(14)
913
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
914
    }
915

            
Bogdan Timofte authored a month ago
916
    private func sessionChartCard(
917
        timeRange: ClosedRange<Date>,
918
        session: ChargeSessionSummary
919
    ) -> some View {
920
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
921
            HStack(spacing: 8) {
922
                Text("Session Chart")
923
                    .font(.headline)
924
                ContextInfoButton(
925
                    title: "Session Chart",
926
                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
927
                )
928
            }
Bogdan Timofte authored a month ago
929

            
Bogdan Timofte authored a month ago
930
            GeometryReader { geometry in
931
                let chartWidth = max(geometry.size.width, 1)
932
                let compactChartLayout = chartWidth < 760
933
                let chartHeight = compactChartLayout ? 290.0 : 350.0
934

            
935
                MeasurementChartView(
936
                    compactLayout: compactChartLayout,
937
                    availableSize: CGSize(width: chartWidth, height: chartHeight),
938
                    timeRange: timeRange,
939
                    showsRangeSelector: false,
940
                    rebasesEnergyToVisibleRangeStart: true
941
                )
Bogdan Timofte authored a month ago
942
                .environmentObject(usbMeter.measurements)
Bogdan Timofte authored a month ago
943
                .frame(maxWidth: .infinity, alignment: .topLeading)
944
            }
945
            .frame(height: 350)
Bogdan Timofte authored a month ago
946
        }
947
        .padding(18)
948
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
949
    }
950

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

            
957
                Spacer(minLength: 0)
958

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

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

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

            
1000
    private var showsWirelessChargerSection: Bool {
1001
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
1002
        return transportMode == .wireless
1003
    }
1004

            
1005
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1006
        if session.autoStopEnabled == false {
1007
            return "Manual"
1008
        }
1009
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1010
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1011
        }
1012
        if session.stopThresholdAmps > 0 {
1013
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1014
        }
1015
        return "Learning"
1016
    }
1017

            
Bogdan Timofte authored a month ago
1018
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1019
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1020
        guard session.status.isOpen else {
1021
            return storedEnergyWh
1022
        }
1023

            
1024
        guard session.meterMACAddress == meterMACAddress else {
1025
            return storedEnergyWh
1026
        }
1027

            
1028
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1029
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1030
        }
1031

            
1032
        return storedEnergyWh
1033
    }
1034

            
Bogdan Timofte authored a month ago
1035
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1036
        let storedChargeAh = session.measuredChargeAh
1037
        guard session.status.isOpen else {
1038
            return storedChargeAh
1039
        }
1040

            
1041
        guard session.meterMACAddress == meterMACAddress else {
1042
            return storedChargeAh
1043
        }
1044

            
1045
        if let baselineChargeAh = session.meterChargeBaselineAh {
1046
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1047
        }
1048

            
1049
        return storedChargeAh
1050
    }
1051

            
1052
    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
1053
        VStack(alignment: .leading, spacing: 8) {
1054
            Text("Battery Checkpoints")
1055
                .font(.subheadline.weight(.semibold))
1056

            
1057
            ForEach(checkpoints, id: \.id) { checkpoint in
1058
                HStack {
1059
                    Text(checkpoint.timestamp.format())
1060
                        .font(.caption2)
1061
                        .foregroundColor(.secondary)
1062
                    Spacer()
1063
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
1064
                        .font(.caption.weight(.semibold))
1065
                    Text("•")
1066
                        .foregroundColor(.secondary)
1067
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
1068
                        .font(.caption2)
1069
                        .foregroundColor(.secondary)
1070
                    Button {
1071
                        pendingCheckpointDeletion = checkpoint
1072
                    } label: {
1073
                        Image(systemName: "trash")
1074
                            .font(.caption.weight(.semibold))
1075
                            .foregroundColor(.red)
1076
                    }
1077
                    .buttonStyle(.plain)
1078
                    .help("Delete checkpoint")
1079
                }
1080
            }
1081
        }
1082
    }
1083

            
Bogdan Timofte authored a month ago
1084
    private func formatDuration(_ duration: TimeInterval) -> String {
1085
        let totalSeconds = Int(duration.rounded(.down))
1086
        let hours = totalSeconds / 3600
1087
        let minutes = (totalSeconds % 3600) / 60
1088
        let seconds = totalSeconds % 60
1089

            
1090
        if hours > 0 {
1091
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1092
        }
1093
        return String(format: "%02d:%02d", minutes, seconds)
1094
    }
1095

            
Bogdan Timofte authored a month ago
1096
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1097
        guard session.chargingTransportMode == .wireless,
1098
              let chargerID = session.chargerID,
1099
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1100
            return nil
1101
        }
1102

            
1103
        guard charger.chargerIdleCurrentAmps == nil else {
1104
            return nil
1105
        }
1106

            
1107
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1108
    }
1109

            
1110
    private func startSession() {
1111
        guard let selectedChargedDevice,
1112
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1113
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1114
            return
1115
        }
1116

            
1117
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1118
        let didStart = appData.startChargeSession(
1119
            for: usbMeter,
1120
            chargedDeviceID: selectedChargedDevice.id,
1121
            chargerID: chargerID,
1122
            chargingTransportMode: chargingTransportMode,
1123
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1124
            autoStopEnabled: false,
1125
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1126
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1127
        )
Bogdan Timofte authored a month ago
1128

            
1129
        if didStart {
1130
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1131
            initialCheckpointMode = .known
1132
        }
1133
    }
1134

            
1135
    private func adjustInitialCheckpoint(by delta: Double) {
1136
        guard initialCheckpointMode == .known else {
1137
            return
Bogdan Timofte authored a month ago
1138
        }
Bogdan Timofte authored a month ago
1139

            
1140
        let currentValue = initialCheckpointValue ?? 0
1141
        let nextValue = min(max(currentValue + delta, 0), 100)
1142
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1143
    }
1144

            
Bogdan Timofte authored a month ago
1145
    private func syncDraftSelections() {
1146
        guard let selectedChargedDevice else {
1147
            draftChargingTransportMode = nil
1148
            draftChargingStateMode = nil
1149
            return
1150
        }
1151

            
1152
        if let openChargeSession {
1153
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1154
            draftChargingStateMode = openChargeSession.chargingStateMode
1155
            return
1156
        }
1157

            
1158
        if let draftChargingTransportMode,
1159
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1160
            self.draftChargingTransportMode = nil
1161
        }
1162

            
1163
        if let draftChargingStateMode,
1164
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1165
            self.draftChargingStateMode = nil
1166
        }
1167

            
1168
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1169
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1170
        }
1171

            
Bogdan Timofte authored a month ago
1172
        if let draftChargingTransportMode {
1173
            draftChargingStateMode = draftChargingStateMode
1174
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1175
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1176
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1177
        }
Bogdan Timofte authored a month ago
1178
    }
Bogdan Timofte authored a month ago
1179

            
1180
    private struct CompactSelectionOption: Identifiable {
1181
        let id: String
1182
        let title: String
1183
        let isSelected: Bool
1184
        let action: () -> Void
1185
    }
1186

            
1187
    private func compactSelectionMenu(
1188
        title: String,
1189
        options: [CompactSelectionOption]
1190
    ) -> some View {
1191
        Menu {
1192
            ForEach(options) { option in
1193
                Button {
1194
                    option.action()
1195
                } label: {
1196
                    if option.isSelected {
1197
                        Label(option.title, systemImage: "checkmark")
1198
                    } else {
1199
                        Text(option.title)
1200
                    }
1201
                }
1202
            }
1203
        } label: {
1204
            HStack(spacing: 8) {
1205
                Text(title)
1206
                    .foregroundColor(.primary)
1207
                Spacer()
1208
                Image(systemName: "chevron.up.chevron.down")
1209
                    .font(.caption.weight(.semibold))
1210
                    .foregroundColor(.secondary)
1211
            }
1212
            .padding(.horizontal, 12)
1213
            .padding(.vertical, 9)
1214
            .frame(width: 180, alignment: .leading)
1215
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1216
        }
1217
        .buttonStyle(.plain)
1218
    }
Bogdan Timofte authored a month ago
1219
}
1220

            
1221
struct ChargeSessionCompletionSheetView: View {
1222
    @EnvironmentObject private var appData: AppData
1223
    @Environment(\.dismiss) private var dismiss
1224

            
1225
    let sessionID: UUID
1226
    let title: String
1227
    let confirmTitle: String
1228
    let explanation: String
1229

            
1230
    @State private var batteryPercent = ""
1231
    @State private var label = "Final"
1232

            
1233
    var body: some View {
1234
        NavigationView {
1235
            Form {
Bogdan Timofte authored a month ago
1236
                Section(
1237
                    header: ContextInfoHeader(
1238
                        title: "Final Checkpoint",
1239
                        message: explanation
1240
                    )
1241
                ) {
Bogdan Timofte authored a month ago
1242
                    TextField("Battery %", text: $batteryPercent)
1243
                        .keyboardType(.decimalPad)
1244
                    TextField("Label", text: $label)
1245
                }
1246

            
1247
                Section {
1248
                    if let sessionWarning {
1249
                        Text(sessionWarning)
1250
                            .font(.footnote)
1251
                            .foregroundColor(.orange)
1252
                    } else if (parsedBatteryPercent ?? 0) >= 99.5 {
1253
                        Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
1254
                            .font(.footnote)
1255
                            .foregroundColor(.secondary)
1256
                    }
1257
                }
1258
            }
1259
            .navigationTitle(title)
1260
            .navigationBarTitleDisplayMode(.inline)
1261
            .toolbar {
1262
                ToolbarItem(placement: .cancellationAction) {
1263
                    Button("Cancel") {
1264
                        dismiss()
1265
                    }
1266
                }
1267
                ToolbarItem(placement: .confirmationAction) {
1268
                    Button(confirmTitle) {
1269
                        guard let batteryPercent = parsedBatteryPercent else {
1270
                            return
1271
                        }
1272

            
1273
                        if appData.stopChargeSession(
1274
                            sessionID: sessionID,
1275
                            finalBatteryPercent: batteryPercent,
1276
                            label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label
1277
                        ) {
1278
                            dismiss()
1279
                        }
1280
                    }
1281
                    .disabled(parsedBatteryPercent == nil)
1282
                }
1283
            }
1284
        }
1285
        .navigationViewStyle(StackNavigationViewStyle())
1286
    }
1287

            
1288
    private var parsedBatteryPercent: Double? {
1289
        let normalized = batteryPercent
1290
            .trimmingCharacters(in: .whitespacesAndNewlines)
1291
            .replacingOccurrences(of: ",", with: ".")
1292
        guard let value = Double(normalized), value >= 0, value <= 100 else {
1293
            return nil
1294
        }
1295
        return value
1296
    }
1297

            
1298
    private var sessionWarning: String? {
1299
        guard let session = appData.chargedDevices
1300
            .flatMap(\.sessions)
1301
            .first(where: { $0.id == sessionID }),
1302
              session.chargingTransportMode == .wireless,
1303
              let chargerID = session.chargerID,
1304
              let charger = appData.chargedDeviceSummary(id: chargerID),
1305
              charger.chargerIdleCurrentAmps == nil else {
1306
            return nil
1307
        }
1308

            
1309
        return "This charger has no idle-current measurement, so the final checkpoint will stop the session but will not learn a wireless stop threshold yet."
1310
    }
1311
}
1312

            
1313
private struct ChargeSessionStopRequest: Identifiable {
1314
    let sessionID: UUID
1315
    let title: String
1316
    let confirmTitle: String
1317
    let explanation: String
Bogdan Timofte authored a month ago
1318

            
Bogdan Timofte authored a month ago
1319
    var id: UUID {
1320
        sessionID
Bogdan Timofte authored a month ago
1321
    }
1322
}