USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1290 lines | 51.042kb
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 checkpointEditorVisibility = false
40
    @State private var editingChargedDevice: ChargedDeviceSummary?
41
    @State private var targetNotificationEditorVisibility = false
Bogdan Timofte authored a month ago
42
    @State private var pendingStopRequest: ChargeSessionStopRequest?
Bogdan Timofte authored a month ago
43
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
Bogdan Timofte authored a month ago
44
    @State private var draftChargingTransportMode: ChargingTransportMode?
45
    @State private var draftChargingStateMode: ChargingStateMode?
Bogdan Timofte authored a month ago
46
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
Bogdan Timofte authored a month ago
47
    @State private var initialCheckpoint = ""
Bogdan Timofte authored a month ago
48
    @State private var showsMeterTotalsInfo = false
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(isPresented: $checkpointEditorVisibility) {
147
            BatteryCheckpointEditorSheetView()
148
                .environmentObject(appData)
149
                .environmentObject(usbMeter)
150
        }
151
        .sheet(item: $editingChargedDevice) { chargedDevice in
152
            ChargedDeviceEditorSheetView(
153
                meterMACAddress: nil,
Bogdan Timofte authored a month ago
154
                kind: chargedDevice.kind,
Bogdan Timofte authored a month ago
155
                chargedDevice: chargedDevice
156
            )
157
            .environmentObject(appData)
158
        }
159
        .sheet(isPresented: $targetNotificationEditorVisibility) {
Bogdan Timofte authored a month ago
160
            if let openChargeSession {
Bogdan Timofte authored a month ago
161
                BatteryTargetNotificationEditorSheetView(
Bogdan Timofte authored a month ago
162
                    sessionID: openChargeSession.id,
163
                    initialTargetPercent: openChargeSession.targetBatteryPercent
Bogdan Timofte authored a month ago
164
                )
165
                .environmentObject(appData)
166
            }
167
        }
Bogdan Timofte authored a month ago
168
        .sheet(item: $pendingStopRequest) { request in
169
            ChargeSessionCompletionSheetView(
170
                sessionID: request.sessionID,
171
                title: request.title,
172
                confirmTitle: request.confirmTitle,
173
                explanation: request.explanation
174
            )
175
            .environmentObject(appData)
176
        }
Bogdan Timofte authored a month ago
177
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
178
            Alert(
179
                title: Text("Delete Battery Checkpoint"),
180
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
181
                primaryButton: .destructive(Text("Delete")) {
182
                    if let openChargeSession {
183
                        _ = appData.deleteBatteryCheckpoint(
184
                            checkpointID: checkpoint.id,
185
                            for: openChargeSession.id
186
                        )
187
                    }
188
                },
189
                secondaryButton: .cancel()
190
            )
191
        }
Bogdan Timofte authored a month ago
192
        .onAppear {
193
            syncDraftSelections()
194
        }
195
        .onChange(of: selectedChargedDevice?.id) { _ in
196
            syncDraftSelections()
197
        }
198
        .onChange(of: openChargeSession?.id) { _ in
199
            syncDraftSelections()
200
        }
Bogdan Timofte authored a month ago
201
    }
202

            
Bogdan Timofte authored a month ago
203
    private var meterMACAddress: String {
204
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
205
    }
206

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

            
211
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
212
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
213
    }
214

            
Bogdan Timofte authored a month ago
215
    private var openChargeSession: ChargeSessionSummary? {
216
        appData.activeChargeSessionSummary(for: meterMACAddress)
217
    }
218

            
Bogdan Timofte authored a month ago
219
    private var showsMeterTotalsCard: Bool {
220
        usbMeter.supportsRecordingView
221
            || usbMeter.supportsDataGroupCommands
222
            || usbMeter.recordedAH > 0
223
            || usbMeter.recordedWH > 0
224
            || usbMeter.recordingDuration > 0
225
    }
226

            
Bogdan Timofte authored a month ago
227
    private var selectedDraftTransportMode: ChargingTransportMode? {
228
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
229
    }
230

            
231
    private var selectedDraftChargingStateMode: ChargingStateMode? {
232
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
233
    }
234

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

            
Bogdan Timofte authored a month ago
248
    private var hasInitialCheckpointInput: Bool {
249
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
250
    }
251

            
252
    private var shouldRequireInitialCheckpoint: Bool {
253
        initialCheckpointMode == .known
254
    }
255

            
Bogdan Timofte authored a month ago
256
    private var requiresExplicitTransportSelection: Bool {
257
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
258
    }
259

            
260
    private var requiresExplicitChargingStateSelection: Bool {
261
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
262
    }
263

            
Bogdan Timofte authored a month ago
264
    private var startRequirements: [SessionStartRequirement] {
265
        var requirements: [SessionStartRequirement] = []
266

            
267
        if openChargeSession != nil {
268
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
269
        }
270

            
Bogdan Timofte authored a month ago
271
        guard let selectedChargedDevice else {
272
            requirements.append(.device)
273
            return requirements
Bogdan Timofte authored a month ago
274
        }
275

            
Bogdan Timofte authored a month ago
276
        guard let chargingTransportMode = selectedDraftTransportMode else {
277
            requirements.append(.chargingType)
278
            return requirements
Bogdan Timofte authored a month ago
279
        }
280

            
Bogdan Timofte authored a month ago
281
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
282
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
283
        }
284

            
Bogdan Timofte authored a month ago
285
        guard let chargingStateMode = selectedDraftChargingStateMode else {
286
            requirements.append(.chargingMode)
287
            return requirements
288
        }
289

            
290
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
291
            requirements.append(.chargingMode)
292
        }
293

            
294
        if chargingTransportMode == .wireless, selectedCharger == nil {
295
            requirements.append(.charger)
296
        }
297

            
298
        if shouldRequireInitialCheckpoint {
299
            if hasInitialCheckpointInput == false {
300
                requirements.append(.initialCheckpointEmpty)
301
            } else if initialCheckpointValue == nil {
302
                requirements.append(.initialCheckpointInvalid)
303
            }
304
        }
305

            
306
        return requirements
307
    }
308

            
309
    private var canStartSession: Bool {
310
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
311
    }
312

            
313
    private var headerStatusTitle: String {
314
        guard let openChargeSession else {
315
            return "Idle"
316
        }
317
        return openChargeSession.status.title
318
    }
319

            
320
    private var headerStatusColor: Color {
321
        guard let openChargeSession else {
322
            return .secondary
323
        }
324

            
325
        switch openChargeSession.status {
326
        case .active:
327
            return .red
328
        case .paused:
329
            return .orange
330
        case .completed:
331
            return .green
332
        case .abandoned:
333
            return .secondary
334
        }
335
    }
336

            
337
    private var sessionChartTimeRange: ClosedRange<Date>? {
338
        guard let openChargeSession else {
339
            return nil
340
        }
341

            
342
        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
343
        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
344
    }
345

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

            
369
        }
370
        .frame(maxWidth: .infinity)
371
        .padding(18)
372
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
373
    }
374

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

            
391
            if let selectedChargedDevice {
Bogdan Timofte authored a month ago
392
                deviceSummary(selectedChargedDevice)
Bogdan Timofte authored a month ago
393

            
Bogdan Timofte authored a month ago
394
                if openChargeSession == nil {
395
                    setupControls(for: selectedChargedDevice)
396
                }
Bogdan Timofte authored a month ago
397

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

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

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

            
424
                VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
425
                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
426
                        .font(.headline)
427

            
Bogdan Timofte authored a month ago
428
                    Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
429
                        .font(.caption.weight(.semibold))
430
                        .foregroundColor(.secondary)
431

            
432
                    Text(chargedDevice.chargingStateAvailability.description)
433
                        .font(.caption2)
434
                        .foregroundColor(.secondary)
435

            
436
                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
437
                        .font(.caption2)
438
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
439

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

            
Bogdan Timofte authored a month ago
447
                Spacer(minLength: 0)
448
            }
Bogdan Timofte authored a month ago
449

            
Bogdan Timofte authored a month ago
450
            if showsWirelessChargerSection {
451
                Divider()
452
                wirelessChargerSection
453
            }
454
        }
455
    }
456

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

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

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

            
Bogdan Timofte authored a month ago
490
            if requiresExplicitChargingStateSelection {
491
                VStack(alignment: .leading, spacing: 8) {
492
                    Text("Charging Mode")
493
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
494

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

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

            
521
            VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
522
                HStack(spacing: 8) {
523
                    Text("Initial Checkpoint")
524
                        .font(.subheadline.weight(.semibold))
525
                    ContextInfoButton(
526
                        title: "Initial Checkpoint",
527
                        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."
528
                    )
529
                }
Bogdan Timofte authored a month ago
530

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

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

            
553
                        TextField("Battery %", text: $initialCheckpoint)
554
                            .keyboardType(.decimalPad)
555
                            .textFieldStyle(.roundedBorder)
556
                            .frame(width: 92)
557

            
558
                        Text("%")
559
                            .font(.subheadline.weight(.semibold))
560
                            .foregroundColor(.secondary)
561

            
562
                        Button {
563
                            adjustInitialCheckpoint(by: 1)
564
                        } label: {
565
                            Image(systemName: "plus.circle")
566
                                .font(.title3)
567
                        }
568
                        .buttonStyle(.plain)
569

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

            
Bogdan Timofte authored a month ago
582
            }
583

            
Bogdan Timofte authored a month ago
584
            VStack(alignment: .leading, spacing: 8) {
585
                Text("Start Requirements")
586
                    .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
587

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

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

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

            
Bogdan Timofte authored a month ago
624
            if let selectedCharger {
625
                HStack(alignment: .top, spacing: 12) {
626
                    ChargedDeviceQRCodeView(
627
                        qrIdentifier: selectedCharger.qrIdentifier,
628
                        side: 62
629
                    )
630

            
631
                    VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
632
                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
Bogdan Timofte authored a month ago
633
                            .font(.subheadline.weight(.semibold))
634

            
635
                        if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
636
                            Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
637
                                .font(.caption)
638
                                .foregroundColor(.secondary)
639
                        }
640

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

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

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

            
673
            if let selectedCharger {
674
                HStack(alignment: .top, spacing: 12) {
675
                    ChargedDeviceQRCodeView(
676
                        qrIdentifier: selectedCharger.qrIdentifier,
677
                        side: 62
678
                    )
679

            
680
                    VStack(alignment: .leading, spacing: 6) {
681
                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
682
                            .font(.subheadline.weight(.semibold))
683

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

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

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

            
724
                Text("Open the wizard and choose the charger there, or preselect one from Charge Record first.")
725
                    .font(.caption)
726
                    .foregroundColor(.secondary)
727
            }
728
        }
729
    }
730

            
Bogdan Timofte authored a month ago
731
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
732
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
733
        return VStack(alignment: .leading, spacing: 12) {
734
            HStack(spacing: 8) {
735
                Text("Charging Monitor")
736
                    .font(.headline)
737
                ContextInfoButton(
738
                    title: "Charging Monitor",
739
                    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."
740
                )
741
            }
Bogdan Timofte authored a month ago
742

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

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

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

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

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

            
787
            if openChargeSession.requiresCompletionConfirmation {
788
                completionConfirmationCard(openChargeSession)
789
            }
790

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

            
Bogdan Timofte authored a month ago
800
            Button("Add Battery Checkpoint") {
801
                checkpointEditorVisibility = true
802
            }
803
            .frame(maxWidth: .infinity)
804
            .padding(.vertical, 10)
805
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
806
            .buttonStyle(.plain)
807

            
808
            Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
Bogdan Timofte authored a month ago
809
                targetNotificationEditorVisibility = true
810
            }
811
            .frame(maxWidth: .infinity)
812
            .padding(.vertical, 10)
813
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
814
            .buttonStyle(.plain)
815

            
Bogdan Timofte authored a month ago
816
            if openChargeSession.targetBatteryPercent != nil {
Bogdan Timofte authored a month ago
817
                Button("Clear Target Notification") {
Bogdan Timofte authored a month ago
818
                    _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
Bogdan Timofte authored a month ago
819
                }
820
                .frame(maxWidth: .infinity)
821
                .padding(.vertical, 10)
822
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
823
                .buttonStyle(.plain)
824
            }
825

            
Bogdan Timofte authored a month ago
826
            if openChargeSession.status == .active {
827
                Button("Pause Session") {
828
                    _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
829
                }
830
                .frame(maxWidth: .infinity)
831
                .padding(.vertical, 10)
832
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
833
                .buttonStyle(.plain)
834
            } else if openChargeSession.status == .paused {
835
                Button("Resume Session") {
836
                    _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
837
                }
838
                .frame(maxWidth: .infinity)
839
                .padding(.vertical, 10)
840
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
841
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
842
            }
843

            
Bogdan Timofte authored a month ago
844
            Button("Stop Session") {
845
                pendingStopRequest = ChargeSessionStopRequest(
846
                    sessionID: openChargeSession.id,
847
                    title: "Stop Session",
848
                    confirmTitle: "Stop",
849
                    explanation: "Record the final battery checkpoint before closing this session."
850
                )
Bogdan Timofte authored a month ago
851
            }
Bogdan Timofte authored a month ago
852
            .frame(maxWidth: .infinity)
853
            .padding(.vertical, 10)
854
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
855
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
856

            
Bogdan Timofte authored a month ago
857
            if !openChargeSession.checkpoints.isEmpty {
Bogdan Timofte authored a month ago
858
                let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed())
Bogdan Timofte authored a month ago
859
                VStack(alignment: .leading, spacing: 8) {
860
                    Text("Battery Checkpoints")
861
                        .font(.subheadline.weight(.semibold))
862

            
Bogdan Timofte authored a month ago
863
                    ForEach(recentCheckpoints, id: \.id) { checkpoint in
Bogdan Timofte authored a month ago
864
                        HStack {
865
                            Text(checkpoint.timestamp.format())
866
                                .font(.caption2)
867
                                .foregroundColor(.secondary)
868
                            Spacer()
869
                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
870
                                .font(.caption.weight(.semibold))
871
                            Text("•")
872
                                .foregroundColor(.secondary)
873
                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
874
                                .font(.caption2)
875
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
876
                            Button {
877
                                pendingCheckpointDeletion = checkpoint
878
                            } label: {
879
                                Image(systemName: "trash")
880
                                    .font(.caption.weight(.semibold))
881
                                    .foregroundColor(.red)
882
                            }
883
                            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
884
                        }
885
                    }
886
                }
887
            }
888
        }
889
        .padding(18)
890
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
891
    }
892

            
Bogdan Timofte authored a month ago
893
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
894
        VStack(alignment: .leading, spacing: 10) {
895
            Text("Completion Needs Confirmation")
896
                .font(.subheadline.weight(.semibold))
897

            
Bogdan Timofte authored a month ago
898
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
899
                Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
Bogdan Timofte authored a month ago
900
                    .font(.caption)
901
                    .foregroundColor(.secondary)
902
            } else {
Bogdan Timofte authored a month ago
903
                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
904
                    .font(.caption)
905
                    .foregroundColor(.secondary)
906
            }
907

            
Bogdan Timofte authored a month ago
908
            Button("Finish Session With Final Checkpoint") {
909
                pendingStopRequest = ChargeSessionStopRequest(
910
                    sessionID: openChargeSession.id,
911
                    title: "Finish Session",
912
                    confirmTitle: "Finish",
913
                    explanation: "Add the final checkpoint before confirming the stop."
914
                )
Bogdan Timofte authored a month ago
915
            }
916
            .frame(maxWidth: .infinity)
917
            .padding(.vertical, 10)
918
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
919
            .buttonStyle(.plain)
920

            
921
            Button("Keep Monitoring") {
Bogdan Timofte authored a month ago
922
                _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
923
            }
924
            .frame(maxWidth: .infinity)
925
            .padding(.vertical, 10)
926
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
927
            .buttonStyle(.plain)
928
        }
929
        .padding(14)
930
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
931
    }
932

            
Bogdan Timofte authored a month ago
933
    private func sessionChartCard(
934
        timeRange: ClosedRange<Date>,
935
        session: ChargeSessionSummary
936
    ) -> some View {
937
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
938
            HStack(spacing: 8) {
939
                Text("Session Chart")
940
                    .font(.headline)
941
                ContextInfoButton(
942
                    title: "Session Chart",
943
                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
944
                )
945
            }
Bogdan Timofte authored a month ago
946

            
Bogdan Timofte authored a month ago
947
            GeometryReader { geometry in
948
                let chartWidth = max(geometry.size.width, 1)
949
                let compactChartLayout = chartWidth < 760
950
                let chartHeight = compactChartLayout ? 290.0 : 350.0
951

            
952
                MeasurementChartView(
953
                    compactLayout: compactChartLayout,
954
                    availableSize: CGSize(width: chartWidth, height: chartHeight),
955
                    timeRange: timeRange,
956
                    showsRangeSelector: false,
957
                    rebasesEnergyToVisibleRangeStart: true
958
                )
Bogdan Timofte authored a month ago
959
                .environmentObject(usbMeter.measurements)
Bogdan Timofte authored a month ago
960
                .frame(maxWidth: .infinity, alignment: .topLeading)
961
            }
962
            .frame(height: 350)
Bogdan Timofte authored a month ago
963
        }
964
        .padding(18)
965
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
966
    }
967

            
968
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
969
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
970
            HStack(spacing: 8) {
971
                Text("Meter Recorder")
972
                    .font(.headline)
973

            
974
                Spacer(minLength: 0)
975

            
976
                Button {
977
                    showsMeterTotalsInfo.toggle()
978
                } label: {
979
                    Image(systemName: "info.circle")
980
                        .font(.body.weight(.semibold))
981
                        .foregroundColor(.secondary)
982
                }
983
                .buttonStyle(.plain)
984
                .accessibilityLabel("Meter recorder info")
985
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
986
                    VStack(alignment: .leading, spacing: 10) {
987
                        Text("Meter Recorder")
988
                            .font(.headline)
989
                        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.")
990
                            .font(.body)
991
                            .fixedSize(horizontal: false, vertical: true)
992
                    }
993
                    .padding(16)
994
                    .frame(width: 280, alignment: .leading)
995
                }
996
            }
Bogdan Timofte authored a month ago
997

            
998
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
999
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1000
                values: [
1001
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1002
                    usbMeter.recordingDurationDescription,
1003
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1004
                ]
1005
            )
Bogdan Timofte authored a month ago
1006

            
1007
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1008
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1009
                    .font(.caption)
1010
                    .foregroundColor(.secondary)
1011
            }
Bogdan Timofte authored a month ago
1012
        }
1013
        .padding(18)
1014
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1015
    }
1016

            
1017
    private var showsWirelessChargerSection: Bool {
1018
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
1019
        return transportMode == .wireless
1020
    }
1021

            
1022
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1023
        if session.autoStopEnabled == false {
1024
            return "Manual"
1025
        }
1026
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1027
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1028
        }
1029
        if session.stopThresholdAmps > 0 {
1030
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1031
        }
1032
        return "Learning"
1033
    }
1034

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

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

            
1045
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1046
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1047
        }
1048

            
1049
        return storedEnergyWh
1050
    }
1051

            
Bogdan Timofte authored a month ago
1052
    private func formatDuration(_ duration: TimeInterval) -> String {
1053
        let totalSeconds = Int(duration.rounded(.down))
1054
        let hours = totalSeconds / 3600
1055
        let minutes = (totalSeconds % 3600) / 60
1056
        let seconds = totalSeconds % 60
1057

            
1058
        if hours > 0 {
1059
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1060
        }
1061
        return String(format: "%02d:%02d", minutes, seconds)
1062
    }
1063

            
Bogdan Timofte authored a month ago
1064
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1065
        guard session.chargingTransportMode == .wireless,
1066
              let chargerID = session.chargerID,
1067
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1068
            return nil
1069
        }
1070

            
1071
        guard charger.chargerIdleCurrentAmps == nil else {
1072
            return nil
1073
        }
1074

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

            
1078
    private func startSession() {
1079
        guard let selectedChargedDevice,
1080
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1081
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1082
            return
1083
        }
1084

            
1085
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1086
        let didStart = appData.startChargeSession(
1087
            for: usbMeter,
1088
            chargedDeviceID: selectedChargedDevice.id,
1089
            chargerID: chargerID,
1090
            chargingTransportMode: chargingTransportMode,
1091
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1092
            autoStopEnabled: false,
1093
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1094
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1095
        )
Bogdan Timofte authored a month ago
1096

            
1097
        if didStart {
1098
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1099
            initialCheckpointMode = .known
1100
        }
1101
    }
1102

            
1103
    private func adjustInitialCheckpoint(by delta: Double) {
1104
        guard initialCheckpointMode == .known else {
1105
            return
Bogdan Timofte authored a month ago
1106
        }
Bogdan Timofte authored a month ago
1107

            
1108
        let currentValue = initialCheckpointValue ?? 0
1109
        let nextValue = min(max(currentValue + delta, 0), 100)
1110
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1111
    }
1112

            
Bogdan Timofte authored a month ago
1113
    private func syncDraftSelections() {
1114
        guard let selectedChargedDevice else {
1115
            draftChargingTransportMode = nil
1116
            draftChargingStateMode = nil
1117
            return
1118
        }
1119

            
1120
        if let openChargeSession {
1121
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1122
            draftChargingStateMode = openChargeSession.chargingStateMode
1123
            return
1124
        }
1125

            
1126
        if let draftChargingTransportMode,
1127
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1128
            self.draftChargingTransportMode = nil
1129
        }
1130

            
1131
        if let draftChargingStateMode,
1132
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1133
            self.draftChargingStateMode = nil
1134
        }
1135

            
1136
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1137
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1138
        }
1139

            
Bogdan Timofte authored a month ago
1140
        if let draftChargingTransportMode {
1141
            draftChargingStateMode = draftChargingStateMode
1142
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1143
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1144
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1145
        }
Bogdan Timofte authored a month ago
1146
    }
Bogdan Timofte authored a month ago
1147

            
1148
    private struct CompactSelectionOption: Identifiable {
1149
        let id: String
1150
        let title: String
1151
        let isSelected: Bool
1152
        let action: () -> Void
1153
    }
1154

            
1155
    private func compactSelectionMenu(
1156
        title: String,
1157
        options: [CompactSelectionOption]
1158
    ) -> some View {
1159
        Menu {
1160
            ForEach(options) { option in
1161
                Button {
1162
                    option.action()
1163
                } label: {
1164
                    if option.isSelected {
1165
                        Label(option.title, systemImage: "checkmark")
1166
                    } else {
1167
                        Text(option.title)
1168
                    }
1169
                }
1170
            }
1171
        } label: {
1172
            HStack(spacing: 8) {
1173
                Text(title)
1174
                    .foregroundColor(.primary)
1175
                Spacer()
1176
                Image(systemName: "chevron.up.chevron.down")
1177
                    .font(.caption.weight(.semibold))
1178
                    .foregroundColor(.secondary)
1179
            }
1180
            .padding(.horizontal, 12)
1181
            .padding(.vertical, 9)
1182
            .frame(width: 180, alignment: .leading)
1183
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1184
        }
1185
        .buttonStyle(.plain)
1186
    }
Bogdan Timofte authored a month ago
1187
}
1188

            
1189
struct ChargeSessionCompletionSheetView: View {
1190
    @EnvironmentObject private var appData: AppData
1191
    @Environment(\.dismiss) private var dismiss
1192

            
1193
    let sessionID: UUID
1194
    let title: String
1195
    let confirmTitle: String
1196
    let explanation: String
1197

            
1198
    @State private var batteryPercent = ""
1199
    @State private var label = "Final"
1200

            
1201
    var body: some View {
1202
        NavigationView {
1203
            Form {
Bogdan Timofte authored a month ago
1204
                Section(
1205
                    header: ContextInfoHeader(
1206
                        title: "Final Checkpoint",
1207
                        message: explanation
1208
                    )
1209
                ) {
Bogdan Timofte authored a month ago
1210
                    TextField("Battery %", text: $batteryPercent)
1211
                        .keyboardType(.decimalPad)
1212
                    TextField("Label", text: $label)
1213
                }
1214

            
1215
                Section {
1216
                    if let sessionWarning {
1217
                        Text(sessionWarning)
1218
                            .font(.footnote)
1219
                            .foregroundColor(.orange)
1220
                    } else if (parsedBatteryPercent ?? 0) >= 99.5 {
1221
                        Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
1222
                            .font(.footnote)
1223
                            .foregroundColor(.secondary)
1224
                    }
1225
                }
1226
            }
1227
            .navigationTitle(title)
1228
            .navigationBarTitleDisplayMode(.inline)
1229
            .toolbar {
1230
                ToolbarItem(placement: .cancellationAction) {
1231
                    Button("Cancel") {
1232
                        dismiss()
1233
                    }
1234
                }
1235
                ToolbarItem(placement: .confirmationAction) {
1236
                    Button(confirmTitle) {
1237
                        guard let batteryPercent = parsedBatteryPercent else {
1238
                            return
1239
                        }
1240

            
1241
                        if appData.stopChargeSession(
1242
                            sessionID: sessionID,
1243
                            finalBatteryPercent: batteryPercent,
1244
                            label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label
1245
                        ) {
1246
                            dismiss()
1247
                        }
1248
                    }
1249
                    .disabled(parsedBatteryPercent == nil)
1250
                }
1251
            }
1252
        }
1253
        .navigationViewStyle(StackNavigationViewStyle())
1254
    }
1255

            
1256
    private var parsedBatteryPercent: Double? {
1257
        let normalized = batteryPercent
1258
            .trimmingCharacters(in: .whitespacesAndNewlines)
1259
            .replacingOccurrences(of: ",", with: ".")
1260
        guard let value = Double(normalized), value >= 0, value <= 100 else {
1261
            return nil
1262
        }
1263
        return value
1264
    }
1265

            
1266
    private var sessionWarning: String? {
1267
        guard let session = appData.chargedDevices
1268
            .flatMap(\.sessions)
1269
            .first(where: { $0.id == sessionID }),
1270
              session.chargingTransportMode == .wireless,
1271
              let chargerID = session.chargerID,
1272
              let charger = appData.chargedDeviceSummary(id: chargerID),
1273
              charger.chargerIdleCurrentAmps == nil else {
1274
            return nil
1275
        }
1276

            
1277
        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."
1278
    }
1279
}
1280

            
1281
private struct ChargeSessionStopRequest: Identifiable {
1282
    let sessionID: UUID
1283
    let title: String
1284
    let confirmTitle: String
1285
    let explanation: String
Bogdan Timofte authored a month ago
1286

            
Bogdan Timofte authored a month ago
1287
    var id: UUID {
1288
        sessionID
Bogdan Timofte authored a month ago
1289
    }
1290
}