USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1216 lines | 48.015kb
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
            } else {
Bogdan Timofte authored a month ago
406
                EmptyView()
Bogdan Timofte authored a month ago
407
            }
408
        }
409
        .padding(18)
410
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
411
    }
412

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
579
            }
580

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

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

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

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

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

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

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

            
638
                        if let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps {
639
                            Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A")
640
                                .font(.caption2)
641
                                .foregroundColor(.secondary)
642
                        } else {
643
                            Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.")
644
                                .font(.caption2)
645
                                .foregroundColor(.orange)
646
                        }
647
                    }
Bogdan Timofte authored a month ago
648
                }
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 func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
658
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
659
        return VStack(alignment: .leading, spacing: 12) {
660
            HStack(spacing: 8) {
661
                Text("Charging Monitor")
662
                    .font(.headline)
663
                ContextInfoButton(
664
                    title: "Charging Monitor",
665
                    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."
666
                )
667
            }
Bogdan Timofte authored a month ago
668

            
669
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
670
                labels: ["Type", "Mode", "Energy", "Duration", "Auto Stop"],
Bogdan Timofte authored a month ago
671
                values: [
Bogdan Timofte authored a month ago
672
                    openChargeSession.chargingTransportMode.title,
673
                    openChargeSession.chargingStateMode.title,
Bogdan Timofte authored a month ago
674
                    "\(displayedEnergyWh.format(decimalDigits: 3)) Wh",
Bogdan Timofte authored a month ago
675
                    formatDuration(max(openChargeSession.effectiveDuration, 0)),
Bogdan Timofte authored a month ago
676
                    autoStopLabel(for: openChargeSession)
Bogdan Timofte authored a month ago
677
                ]
678
            )
679

            
Bogdan Timofte authored a month ago
680
            if openChargeSession.stopThresholdAmps > 0 {
681
                Text("Meter monitor threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
682
                    .font(.caption)
683
                    .foregroundColor(.secondary)
684
            }
685

            
686
            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
687
                for: openChargeSession,
688
                effectiveEnergyWhOverride: displayedEnergyWh
689
            ) {
Bogdan Timofte authored a month ago
690
                VStack(alignment: .leading, spacing: 4) {
691
                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
692
                        .font(.caption.weight(.semibold))
693
                    Text(
694
                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
695
                    )
696
                    .font(.caption2)
697
                    .foregroundColor(.secondary)
698
                }
699
            }
700

            
Bogdan Timofte authored a month ago
701
            if let sessionWarning = sessionWarning(for: openChargeSession) {
702
                Text(sessionWarning)
703
                    .font(.caption)
704
                    .foregroundColor(.orange)
705
            }
706

            
707
            if openChargeSession.isPaused {
708
                Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.")
709
                    .font(.caption)
710
                    .foregroundColor(.secondary)
711
            }
712

            
713
            if openChargeSession.requiresCompletionConfirmation {
714
                completionConfirmationCard(openChargeSession)
715
            }
716

            
717
            if let targetBatteryPercent = openChargeSession.targetBatteryPercent {
Bogdan Timofte authored a month ago
718
                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
719
                    .font(.caption.weight(.semibold))
720
            } else {
721
                Text("No target battery notification configured.")
722
                    .font(.caption)
723
                    .foregroundColor(.secondary)
724
            }
725

            
Bogdan Timofte authored a month ago
726
            Button("Add Battery Checkpoint") {
727
                checkpointEditorVisibility = true
728
            }
729
            .frame(maxWidth: .infinity)
730
            .padding(.vertical, 10)
731
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
732
            .buttonStyle(.plain)
733

            
734
            Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
Bogdan Timofte authored a month ago
735
                targetNotificationEditorVisibility = true
736
            }
737
            .frame(maxWidth: .infinity)
738
            .padding(.vertical, 10)
739
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
740
            .buttonStyle(.plain)
741

            
Bogdan Timofte authored a month ago
742
            if openChargeSession.targetBatteryPercent != nil {
Bogdan Timofte authored a month ago
743
                Button("Clear Target Notification") {
Bogdan Timofte authored a month ago
744
                    _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
Bogdan Timofte authored a month ago
745
                }
746
                .frame(maxWidth: .infinity)
747
                .padding(.vertical, 10)
748
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
749
                .buttonStyle(.plain)
750
            }
751

            
Bogdan Timofte authored a month ago
752
            if openChargeSession.status == .active {
753
                Button("Pause Session") {
754
                    _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
755
                }
756
                .frame(maxWidth: .infinity)
757
                .padding(.vertical, 10)
758
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
759
                .buttonStyle(.plain)
760
            } else if openChargeSession.status == .paused {
761
                Button("Resume Session") {
762
                    _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
763
                }
764
                .frame(maxWidth: .infinity)
765
                .padding(.vertical, 10)
766
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
767
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
768
            }
769

            
Bogdan Timofte authored a month ago
770
            Button("Stop Session") {
771
                pendingStopRequest = ChargeSessionStopRequest(
772
                    sessionID: openChargeSession.id,
773
                    title: "Stop Session",
774
                    confirmTitle: "Stop",
775
                    explanation: "Record the final battery checkpoint before closing this session."
776
                )
Bogdan Timofte authored a month ago
777
            }
Bogdan Timofte authored a month ago
778
            .frame(maxWidth: .infinity)
779
            .padding(.vertical, 10)
780
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
781
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
782

            
Bogdan Timofte authored a month ago
783
            if !openChargeSession.checkpoints.isEmpty {
Bogdan Timofte authored a month ago
784
                let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed())
Bogdan Timofte authored a month ago
785
                VStack(alignment: .leading, spacing: 8) {
786
                    Text("Battery Checkpoints")
787
                        .font(.subheadline.weight(.semibold))
788

            
Bogdan Timofte authored a month ago
789
                    ForEach(recentCheckpoints, id: \.id) { checkpoint in
Bogdan Timofte authored a month ago
790
                        HStack {
791
                            Text(checkpoint.timestamp.format())
792
                                .font(.caption2)
793
                                .foregroundColor(.secondary)
794
                            Spacer()
795
                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
796
                                .font(.caption.weight(.semibold))
797
                            Text("•")
798
                                .foregroundColor(.secondary)
799
                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
800
                                .font(.caption2)
801
                                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
802
                            Button {
803
                                pendingCheckpointDeletion = checkpoint
804
                            } label: {
805
                                Image(systemName: "trash")
806
                                    .font(.caption.weight(.semibold))
807
                                    .foregroundColor(.red)
808
                            }
809
                            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
810
                        }
811
                    }
812
                }
813
            }
814
        }
815
        .padding(18)
816
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
817
    }
818

            
Bogdan Timofte authored a month ago
819
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
820
        VStack(alignment: .leading, spacing: 10) {
821
            Text("Completion Needs Confirmation")
822
                .font(.subheadline.weight(.semibold))
823

            
Bogdan Timofte authored a month ago
824
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
825
                Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
Bogdan Timofte authored a month ago
826
                    .font(.caption)
827
                    .foregroundColor(.secondary)
828
            } else {
Bogdan Timofte authored a month ago
829
                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
830
                    .font(.caption)
831
                    .foregroundColor(.secondary)
832
            }
833

            
Bogdan Timofte authored a month ago
834
            Button("Finish Session With Final Checkpoint") {
835
                pendingStopRequest = ChargeSessionStopRequest(
836
                    sessionID: openChargeSession.id,
837
                    title: "Finish Session",
838
                    confirmTitle: "Finish",
839
                    explanation: "Add the final checkpoint before confirming the stop."
840
                )
Bogdan Timofte authored a month ago
841
            }
842
            .frame(maxWidth: .infinity)
843
            .padding(.vertical, 10)
844
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
845
            .buttonStyle(.plain)
846

            
847
            Button("Keep Monitoring") {
Bogdan Timofte authored a month ago
848
                _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
849
            }
850
            .frame(maxWidth: .infinity)
851
            .padding(.vertical, 10)
852
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
853
            .buttonStyle(.plain)
854
        }
855
        .padding(14)
856
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
857
    }
858

            
Bogdan Timofte authored a month ago
859
    private func sessionChartCard(
860
        timeRange: ClosedRange<Date>,
861
        session: ChargeSessionSummary
862
    ) -> some View {
863
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
864
            HStack(spacing: 8) {
865
                Text("Session Chart")
866
                    .font(.headline)
867
                ContextInfoButton(
868
                    title: "Session Chart",
869
                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
870
                )
871
            }
Bogdan Timofte authored a month ago
872

            
Bogdan Timofte authored a month ago
873
            GeometryReader { geometry in
874
                let chartWidth = max(geometry.size.width, 1)
875
                let compactChartLayout = chartWidth < 760
876
                let chartHeight = compactChartLayout ? 290.0 : 350.0
877

            
878
                MeasurementChartView(
879
                    compactLayout: compactChartLayout,
880
                    availableSize: CGSize(width: chartWidth, height: chartHeight),
881
                    timeRange: timeRange,
882
                    showsRangeSelector: false,
883
                    rebasesEnergyToVisibleRangeStart: true
884
                )
Bogdan Timofte authored a month ago
885
                .environmentObject(usbMeter.measurements)
Bogdan Timofte authored a month ago
886
                .frame(maxWidth: .infinity, alignment: .topLeading)
887
            }
888
            .frame(height: 350)
Bogdan Timofte authored a month ago
889
        }
890
        .padding(18)
891
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
892
    }
893

            
894
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
895
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
896
            HStack(spacing: 8) {
897
                Text("Meter Recorder")
898
                    .font(.headline)
899

            
900
                Spacer(minLength: 0)
901

            
902
                Button {
903
                    showsMeterTotalsInfo.toggle()
904
                } label: {
905
                    Image(systemName: "info.circle")
906
                        .font(.body.weight(.semibold))
907
                        .foregroundColor(.secondary)
908
                }
909
                .buttonStyle(.plain)
910
                .accessibilityLabel("Meter recorder info")
911
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
912
                    VStack(alignment: .leading, spacing: 10) {
913
                        Text("Meter Recorder")
914
                            .font(.headline)
915
                        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.")
916
                            .font(.body)
917
                            .fixedSize(horizontal: false, vertical: true)
918
                    }
919
                    .padding(16)
920
                    .frame(width: 280, alignment: .leading)
921
                }
922
            }
Bogdan Timofte authored a month ago
923

            
924
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
925
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
926
                values: [
927
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
928
                    usbMeter.recordingDurationDescription,
929
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
930
                ]
931
            )
Bogdan Timofte authored a month ago
932

            
933
            if let recordingBootedAt = usbMeter.recordingBootedAt {
934
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
935
                    .font(.caption)
936
                    .foregroundColor(.secondary)
937
            }
Bogdan Timofte authored a month ago
938
        }
939
        .padding(18)
940
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
941
    }
942

            
943
    private var showsWirelessChargerSection: Bool {
944
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
945
        return transportMode == .wireless
946
    }
947

            
948
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
949
        if session.autoStopEnabled == false {
950
            return "Manual"
951
        }
952
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
953
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
954
        }
955
        if session.stopThresholdAmps > 0 {
956
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
957
        }
958
        return "Learning"
959
    }
960

            
Bogdan Timofte authored a month ago
961
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
962
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
963
        guard session.status.isOpen else {
964
            return storedEnergyWh
965
        }
966

            
967
        guard session.meterMACAddress == meterMACAddress else {
968
            return storedEnergyWh
969
        }
970

            
971
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
972
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
973
        }
974

            
975
        return storedEnergyWh
976
    }
977

            
Bogdan Timofte authored a month ago
978
    private func formatDuration(_ duration: TimeInterval) -> String {
979
        let totalSeconds = Int(duration.rounded(.down))
980
        let hours = totalSeconds / 3600
981
        let minutes = (totalSeconds % 3600) / 60
982
        let seconds = totalSeconds % 60
983

            
984
        if hours > 0 {
985
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
986
        }
987
        return String(format: "%02d:%02d", minutes, seconds)
988
    }
989

            
Bogdan Timofte authored a month ago
990
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
991
        guard session.chargingTransportMode == .wireless,
992
              let chargerID = session.chargerID,
993
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
994
            return nil
995
        }
996

            
997
        guard charger.chargerIdleCurrentAmps == nil else {
998
            return nil
999
        }
1000

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

            
1004
    private func startSession() {
1005
        guard let selectedChargedDevice,
1006
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1007
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1008
            return
1009
        }
1010

            
1011
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1012
        let didStart = appData.startChargeSession(
1013
            for: usbMeter,
1014
            chargedDeviceID: selectedChargedDevice.id,
1015
            chargerID: chargerID,
1016
            chargingTransportMode: chargingTransportMode,
1017
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1018
            autoStopEnabled: false,
1019
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1020
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1021
        )
Bogdan Timofte authored a month ago
1022

            
1023
        if didStart {
1024
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1025
            initialCheckpointMode = .known
1026
        }
1027
    }
1028

            
1029
    private func adjustInitialCheckpoint(by delta: Double) {
1030
        guard initialCheckpointMode == .known else {
1031
            return
Bogdan Timofte authored a month ago
1032
        }
Bogdan Timofte authored a month ago
1033

            
1034
        let currentValue = initialCheckpointValue ?? 0
1035
        let nextValue = min(max(currentValue + delta, 0), 100)
1036
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1037
    }
1038

            
Bogdan Timofte authored a month ago
1039
    private func syncDraftSelections() {
1040
        guard let selectedChargedDevice else {
1041
            draftChargingTransportMode = nil
1042
            draftChargingStateMode = nil
1043
            return
1044
        }
1045

            
1046
        if let openChargeSession {
1047
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1048
            draftChargingStateMode = openChargeSession.chargingStateMode
1049
            return
1050
        }
1051

            
1052
        if let draftChargingTransportMode,
1053
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1054
            self.draftChargingTransportMode = nil
1055
        }
1056

            
1057
        if let draftChargingStateMode,
1058
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1059
            self.draftChargingStateMode = nil
1060
        }
1061

            
1062
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1063
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1064
        }
1065

            
Bogdan Timofte authored a month ago
1066
        if let draftChargingTransportMode {
1067
            draftChargingStateMode = draftChargingStateMode
1068
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1069
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1070
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1071
        }
Bogdan Timofte authored a month ago
1072
    }
Bogdan Timofte authored a month ago
1073

            
1074
    private struct CompactSelectionOption: Identifiable {
1075
        let id: String
1076
        let title: String
1077
        let isSelected: Bool
1078
        let action: () -> Void
1079
    }
1080

            
1081
    private func compactSelectionMenu(
1082
        title: String,
1083
        options: [CompactSelectionOption]
1084
    ) -> some View {
1085
        Menu {
1086
            ForEach(options) { option in
1087
                Button {
1088
                    option.action()
1089
                } label: {
1090
                    if option.isSelected {
1091
                        Label(option.title, systemImage: "checkmark")
1092
                    } else {
1093
                        Text(option.title)
1094
                    }
1095
                }
1096
            }
1097
        } label: {
1098
            HStack(spacing: 8) {
1099
                Text(title)
1100
                    .foregroundColor(.primary)
1101
                Spacer()
1102
                Image(systemName: "chevron.up.chevron.down")
1103
                    .font(.caption.weight(.semibold))
1104
                    .foregroundColor(.secondary)
1105
            }
1106
            .padding(.horizontal, 12)
1107
            .padding(.vertical, 9)
1108
            .frame(width: 180, alignment: .leading)
1109
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1110
        }
1111
        .buttonStyle(.plain)
1112
    }
Bogdan Timofte authored a month ago
1113
}
1114

            
1115
struct ChargeSessionCompletionSheetView: View {
1116
    @EnvironmentObject private var appData: AppData
1117
    @Environment(\.dismiss) private var dismiss
1118

            
1119
    let sessionID: UUID
1120
    let title: String
1121
    let confirmTitle: String
1122
    let explanation: String
1123

            
1124
    @State private var batteryPercent = ""
1125
    @State private var label = "Final"
1126

            
1127
    var body: some View {
1128
        NavigationView {
1129
            Form {
Bogdan Timofte authored a month ago
1130
                Section(
1131
                    header: ContextInfoHeader(
1132
                        title: "Final Checkpoint",
1133
                        message: explanation
1134
                    )
1135
                ) {
Bogdan Timofte authored a month ago
1136
                    TextField("Battery %", text: $batteryPercent)
1137
                        .keyboardType(.decimalPad)
1138
                    TextField("Label", text: $label)
1139
                }
1140

            
1141
                Section {
1142
                    if let sessionWarning {
1143
                        Text(sessionWarning)
1144
                            .font(.footnote)
1145
                            .foregroundColor(.orange)
1146
                    } else if (parsedBatteryPercent ?? 0) >= 99.5 {
1147
                        Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
1148
                            .font(.footnote)
1149
                            .foregroundColor(.secondary)
1150
                    }
1151
                }
1152
            }
1153
            .navigationTitle(title)
1154
            .navigationBarTitleDisplayMode(.inline)
1155
            .toolbar {
1156
                ToolbarItem(placement: .cancellationAction) {
1157
                    Button("Cancel") {
1158
                        dismiss()
1159
                    }
1160
                }
1161
                ToolbarItem(placement: .confirmationAction) {
1162
                    Button(confirmTitle) {
1163
                        guard let batteryPercent = parsedBatteryPercent else {
1164
                            return
1165
                        }
1166

            
1167
                        if appData.stopChargeSession(
1168
                            sessionID: sessionID,
1169
                            finalBatteryPercent: batteryPercent,
1170
                            label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label
1171
                        ) {
1172
                            dismiss()
1173
                        }
1174
                    }
1175
                    .disabled(parsedBatteryPercent == nil)
1176
                }
1177
            }
1178
        }
1179
        .navigationViewStyle(StackNavigationViewStyle())
1180
    }
1181

            
1182
    private var parsedBatteryPercent: Double? {
1183
        let normalized = batteryPercent
1184
            .trimmingCharacters(in: .whitespacesAndNewlines)
1185
            .replacingOccurrences(of: ",", with: ".")
1186
        guard let value = Double(normalized), value >= 0, value <= 100 else {
1187
            return nil
1188
        }
1189
        return value
1190
    }
1191

            
1192
    private var sessionWarning: String? {
1193
        guard let session = appData.chargedDevices
1194
            .flatMap(\.sessions)
1195
            .first(where: { $0.id == sessionID }),
1196
              session.chargingTransportMode == .wireless,
1197
              let chargerID = session.chargerID,
1198
              let charger = appData.chargedDeviceSummary(id: chargerID),
1199
              charger.chargerIdleCurrentAmps == nil else {
1200
            return nil
1201
        }
1202

            
1203
        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."
1204
    }
1205
}
1206

            
1207
private struct ChargeSessionStopRequest: Identifiable {
1208
    let sessionID: UUID
1209
    let title: String
1210
    let confirmTitle: String
1211
    let explanation: String
Bogdan Timofte authored a month ago
1212

            
Bogdan Timofte authored a month ago
1213
    var id: UUID {
1214
        sessionID
Bogdan Timofte authored a month ago
1215
    }
1216
}