USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
951 lines | 37.893kb
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
    @EnvironmentObject private var appData: AppData
16
    @EnvironmentObject private var usbMeter: Meter
Bogdan Timofte authored a month ago
17

            
Bogdan Timofte authored a month ago
18
    @State private var chargedDeviceLibraryVisibility = false
19
    @State private var chargerLibraryVisibility = false
20
    @State private var checkpointEditorVisibility = false
21
    @State private var editingChargedDevice: ChargedDeviceSummary?
22
    @State private var targetNotificationEditorVisibility = false
Bogdan Timofte authored a month ago
23
    @State private var pendingStopRequest: ChargeSessionStopRequest?
24
    @State private var draftChargingTransportMode: ChargingTransportMode?
25
    @State private var draftChargingStateMode: ChargingStateMode?
26
    @State private var draftAutoStopEnabled = true
27
    @State private var initialCheckpoint = ""
Bogdan Timofte authored a month ago
28

            
29
    var body: some View {
30
        ScrollView {
31
            VStack(spacing: 16) {
Bogdan Timofte authored a month ago
32
                headerCard
33
                sessionSetupCard
Bogdan Timofte authored a month ago
34

            
Bogdan Timofte authored a month ago
35
                if let openChargeSession {
36
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
37

            
Bogdan Timofte authored a month ago
38
                    if let sessionChartTimeRange {
39
                        sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession)
Bogdan Timofte authored a month ago
40
                    }
41
                }
42

            
43
                if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
Bogdan Timofte authored a month ago
44
                    meterTotalsCard
Bogdan Timofte authored a month ago
45
                }
46
            }
47
            .padding()
48
        }
49
        .background(
50
            LinearGradient(
51
                colors: [.pink.opacity(0.14), Color.clear],
52
                startPoint: .topLeading,
53
                endPoint: .bottomTrailing
54
            )
55
            .ignoresSafeArea()
56
        )
57
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
58
            ChargedDeviceLibrarySheetView(
59
                visibility: $chargedDeviceLibraryVisibility,
Bogdan Timofte authored a month ago
60
                meterMACAddress: meterMACAddress,
Bogdan Timofte authored a month ago
61
                meterTint: usbMeter.color,
62
                mode: .device
63
            )
64
            .environmentObject(appData)
65
        }
66
        .sheet(isPresented: $chargerLibraryVisibility) {
67
            ChargedDeviceLibrarySheetView(
68
                visibility: $chargerLibraryVisibility,
Bogdan Timofte authored a month ago
69
                meterMACAddress: meterMACAddress,
Bogdan Timofte authored a month ago
70
                meterTint: usbMeter.color,
71
                mode: .charger
72
            )
73
            .environmentObject(appData)
74
        }
75
        .sheet(isPresented: $checkpointEditorVisibility) {
76
            BatteryCheckpointEditorSheetView()
77
                .environmentObject(appData)
78
                .environmentObject(usbMeter)
79
        }
80
        .sheet(item: $editingChargedDevice) { chargedDevice in
81
            ChargedDeviceEditorSheetView(
82
                meterMACAddress: nil,
83
                chargedDevice: chargedDevice
84
            )
85
            .environmentObject(appData)
86
        }
87
        .sheet(isPresented: $targetNotificationEditorVisibility) {
Bogdan Timofte authored a month ago
88
            if let openChargeSession {
Bogdan Timofte authored a month ago
89
                BatteryTargetNotificationEditorSheetView(
Bogdan Timofte authored a month ago
90
                    sessionID: openChargeSession.id,
91
                    initialTargetPercent: openChargeSession.targetBatteryPercent
Bogdan Timofte authored a month ago
92
                )
93
                .environmentObject(appData)
94
            }
95
        }
Bogdan Timofte authored a month ago
96
        .sheet(item: $pendingStopRequest) { request in
97
            ChargeSessionCompletionSheetView(
98
                sessionID: request.sessionID,
99
                title: request.title,
100
                confirmTitle: request.confirmTitle,
101
                explanation: request.explanation
102
            )
103
            .environmentObject(appData)
104
        }
105
        .onAppear {
106
            syncDraftSelections()
107
        }
108
        .onChange(of: selectedChargedDevice?.id) { _ in
109
            syncDraftSelections()
110
        }
111
        .onChange(of: openChargeSession?.id) { _ in
112
            syncDraftSelections()
113
        }
Bogdan Timofte authored a month ago
114
    }
115

            
Bogdan Timofte authored a month ago
116
    private var meterMACAddress: String {
117
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
118
    }
119

            
Bogdan Timofte authored a month ago
120
    private var selectedChargedDevice: ChargedDeviceSummary? {
121
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
122
    }
123

            
124
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
125
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
126
    }
127

            
Bogdan Timofte authored a month ago
128
    private var openChargeSession: ChargeSessionSummary? {
129
        appData.activeChargeSessionSummary(for: meterMACAddress)
130
    }
131

            
132
    private var selectedDraftTransportMode: ChargingTransportMode? {
133
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
134
    }
135

            
136
    private var selectedDraftChargingStateMode: ChargingStateMode? {
137
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
138
    }
139

            
140
    private var selectedDraftSessionKind: ChargeSessionKind? {
141
        guard let chargingTransportMode = selectedDraftTransportMode,
142
              let chargingStateMode = selectedDraftChargingStateMode else {
143
            return nil
144
        }
145

            
146
        return ChargeSessionKind(
147
            chargingTransportMode: chargingTransportMode,
148
            chargingStateMode: chargingStateMode
149
        )
150
    }
151

            
152
    private var selectedDraftStopThreshold: Double? {
153
        guard let selectedChargedDevice,
154
              let chargingTransportMode = selectedDraftTransportMode else {
155
            return nil
156
        }
157

            
158
        return selectedChargedDevice.resolvedCompletionCurrentAmps(
159
            for: chargingTransportMode,
160
            chargingStateMode: selectedDraftChargingStateMode
161
        )
162
    }
163

            
164
    private var initialCheckpointValue: Double? {
165
        let normalized = initialCheckpoint
166
            .trimmingCharacters(in: .whitespacesAndNewlines)
167
            .replacingOccurrences(of: ",", with: ".")
168
        guard let value = Double(normalized), value >= 0, value <= 100 else {
169
            return nil
170
        }
171
        return value
172
    }
173

            
174
    private var requiresExplicitTransportSelection: Bool {
175
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
176
    }
177

            
178
    private var requiresExplicitChargingStateSelection: Bool {
179
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
180
    }
181

            
182
    private var canStartSession: Bool {
183
        guard openChargeSession == nil,
184
              let selectedChargedDevice,
185
              let chargingTransportMode = selectedDraftTransportMode,
186
              let chargingStateMode = selectedDraftChargingStateMode,
187
              let initialCheckpointValue else {
188
            return false
189
        }
190

            
191
        guard selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) else {
192
            return false
193
        }
194

            
195
        guard selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) else {
196
            return false
197
        }
198

            
199
        if chargingTransportMode == .wireless {
200
            return selectedCharger != nil
201
        }
202

            
203
        return true
204
    }
205

            
206
    private var headerStatusTitle: String {
207
        guard let openChargeSession else {
208
            return "Idle"
209
        }
210
        return openChargeSession.status.title
211
    }
212

            
213
    private var headerStatusColor: Color {
214
        guard let openChargeSession else {
215
            return .secondary
216
        }
217

            
218
        switch openChargeSession.status {
219
        case .active:
220
            return .red
221
        case .paused:
222
            return .orange
223
        case .completed:
224
            return .green
225
        case .abandoned:
226
            return .secondary
227
        }
228
    }
229

            
230
    private var sessionChartTimeRange: ClosedRange<Date>? {
231
        guard let openChargeSession else {
232
            return nil
233
        }
234

            
235
        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
236
        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
237
    }
238

            
239
    private var draftAutoStopDescription: String {
240
        guard let chargingTransportMode = selectedDraftTransportMode else {
241
            return "Choose the charging type before starting the session."
242
        }
243

            
244
        if chargingTransportMode == .wireless, selectedCharger == nil {
245
            return "Wireless sessions need a selected charger before they can start."
246
        }
247

            
248
        if draftAutoStopEnabled == false {
249
            return "The session starts open-ended and will stop only when you pause or stop it manually."
250
        }
251

            
252
        if let setupWarning = setupWirelessThresholdWarning {
253
            return setupWarning
254
        }
255

            
256
        if let selectedDraftSessionKind, let selectedDraftStopThreshold {
257
            return "Auto-stop is ready for \(selectedDraftSessionKind.shortTitle.lowercased()) sessions at about \(selectedDraftStopThreshold.format(decimalDigits: 2)) A."
258
        }
259

            
260
        return "No stop threshold is known for this charging type yet, so the session starts open-ended."
261
    }
262

            
263
    private var setupWirelessThresholdWarning: String? {
264
        guard selectedDraftTransportMode == .wireless else {
265
            return nil
266
        }
267

            
268
        guard let selectedCharger else {
269
            return nil
270
        }
271

            
272
        guard selectedCharger.chargerIdleCurrentAmps == nil else {
273
            return nil
274
        }
275

            
276
        return "This charger has no idle-current measurement. Wireless sessions can still be recorded, but they cannot learn or auto-apply the final stop threshold yet."
277
    }
278

            
279
    private var headerCard: some View {
280
        VStack(alignment: .leading, spacing: 8) {
281
            HStack {
282
                Text("Charging Session")
283
                    .font(.system(.title3, design: .rounded).weight(.bold))
284
                Spacer()
285
                Text(headerStatusTitle)
286
                    .font(.caption.weight(.bold))
287
                    .foregroundColor(headerStatusColor)
288
                    .padding(.horizontal, 10)
289
                    .padding(.vertical, 6)
290
                    .meterCard(
291
                        tint: headerStatusColor,
292
                        fillOpacity: 0.18,
293
                        strokeOpacity: 0.24,
294
                        cornerRadius: 999
295
                    )
296
            }
297

            
298
            Text("Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit.")
299
                .font(.footnote)
300
                .foregroundColor(.secondary)
301
        }
302
        .frame(maxWidth: .infinity)
303
        .padding(18)
304
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
305
    }
306

            
307
    private var sessionSetupCard: some View {
308
        VStack(alignment: .leading, spacing: 14) {
Bogdan Timofte authored a month ago
309
            HStack {
Bogdan Timofte authored a month ago
310
                Text(openChargeSession == nil ? "Session Setup" : "Session Context")
Bogdan Timofte authored a month ago
311
                    .font(.headline)
312
                Spacer()
313
                Button("Library") {
314
                    chargedDeviceLibraryVisibility = true
315
                }
Bogdan Timofte authored a month ago
316
                .disabled(openChargeSession != nil)
Bogdan Timofte authored a month ago
317
            }
318

            
319
            if let selectedChargedDevice {
Bogdan Timofte authored a month ago
320
                deviceSummary(selectedChargedDevice)
Bogdan Timofte authored a month ago
321

            
Bogdan Timofte authored a month ago
322
                if openChargeSession == nil {
323
                    setupControls(for: selectedChargedDevice)
324
                }
Bogdan Timofte authored a month ago
325

            
Bogdan Timofte authored a month ago
326
                Button("Edit Device") {
327
                    editingChargedDevice = selectedChargedDevice
328
                }
329
                .frame(maxWidth: .infinity)
330
                .padding(.vertical, 10)
331
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
332
                .buttonStyle(.plain)
333
            } else {
334
                Text("Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection.")
335
                    .font(.footnote)
336
                    .foregroundColor(.secondary)
337
            }
338
        }
339
        .padding(18)
340
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
341
    }
342

            
343
    private func deviceSummary(_ chargedDevice: ChargedDeviceSummary) -> some View {
344
        VStack(alignment: .leading, spacing: 12) {
345
            HStack(alignment: .top, spacing: 14) {
346
                ChargedDeviceQRCodeView(
347
                    qrIdentifier: chargedDevice.qrIdentifier,
348
                    side: 88
349
                )
350

            
351
                VStack(alignment: .leading, spacing: 8) {
352
                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
353
                        .font(.headline)
354

            
355
                    Text(chargedDevice.deviceClass.title)
356
                        .font(.caption.weight(.semibold))
357
                        .foregroundColor(.secondary)
358

            
359
                    Text(chargedDevice.chargingStateAvailability.description)
360
                        .font(.caption2)
361
                        .foregroundColor(.secondary)
362

            
363
                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
364
                        .font(.caption2)
365
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
366

            
Bogdan Timofte authored a month ago
367
                    if let selectedDraftSessionKind,
368
                       let threshold = chargedDevice.resolvedCompletionCurrentAmps(
369
                        for: selectedDraftSessionKind.chargingTransportMode,
370
                        chargingStateMode: selectedDraftSessionKind.chargingStateMode
371
                       ) {
372
                        Text("\(selectedDraftSessionKind.shortTitle) stop current: \(threshold.format(decimalDigits: 2)) A")
Bogdan Timofte authored a month ago
373
                            .font(.caption2)
374
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
375
                    } else if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh(
376
                        for: chargedDevice.preferredChargingTransportMode
377
                    ) {
378
                        Text("Estimated \(chargedDevice.preferredChargingTransportMode.title.lowercased()) capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
379
                            .font(.caption2)
380
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
381
                    }
382
                }
Bogdan Timofte authored a month ago
383

            
Bogdan Timofte authored a month ago
384
                Spacer(minLength: 0)
385
            }
Bogdan Timofte authored a month ago
386

            
Bogdan Timofte authored a month ago
387
            if showsWirelessChargerSection {
388
                Divider()
389
                wirelessChargerSection
390
            }
391
        }
392
    }
393

            
394
    private func setupControls(for chargedDevice: ChargedDeviceSummary) -> some View {
395
        VStack(alignment: .leading, spacing: 12) {
396
            if requiresExplicitTransportSelection {
397
                VStack(alignment: .leading, spacing: 8) {
398
                    Text("Charging Type")
399
                        .font(.subheadline.weight(.semibold))
400

            
401
                    Picker("Charging Type", selection: $draftChargingTransportMode) {
402
                        ForEach(chargedDevice.supportedChargingModes) { chargingTransportMode in
403
                            Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
404
                                .tag(Optional(chargingTransportMode))
Bogdan Timofte authored a month ago
405
                        }
406
                    }
Bogdan Timofte authored a month ago
407
                    .pickerStyle(.segmented)
Bogdan Timofte authored a month ago
408

            
Bogdan Timofte authored a month ago
409
                    if draftChargingTransportMode == nil {
410
                        Text("Pick the charging type explicitly before starting.")
411
                            .font(.caption2)
412
                            .foregroundColor(.orange)
413
                    }
Bogdan Timofte authored a month ago
414
                }
Bogdan Timofte authored a month ago
415
            } else if let chargingTransportMode = chargedDevice.supportedChargingModes.first {
416
                Label(
417
                    "Charging type: \(chargingTransportMode.title)",
418
                    systemImage: chargingTransportMode.symbolName
419
                )
420
                .font(.subheadline.weight(.semibold))
421
            }
Bogdan Timofte authored a month ago
422

            
Bogdan Timofte authored a month ago
423
            if requiresExplicitChargingStateSelection {
424
                VStack(alignment: .leading, spacing: 8) {
425
                    Text("Charging Mode")
426
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
427

            
Bogdan Timofte authored a month ago
428
                    Picker("Charging Mode", selection: $draftChargingStateMode) {
429
                        ForEach(chargedDevice.supportedChargingStateModes) { chargingStateMode in
430
                            Text(chargingStateMode.title)
431
                                .tag(Optional(chargingStateMode))
Bogdan Timofte authored a month ago
432
                        }
Bogdan Timofte authored a month ago
433
                    }
434
                    .pickerStyle(.segmented)
Bogdan Timofte authored a month ago
435

            
Bogdan Timofte authored a month ago
436
                    if draftChargingStateMode == nil {
437
                        Text("Pick whether the device is on or off for this session.")
438
                            .font(.caption2)
439
                            .foregroundColor(.orange)
Bogdan Timofte authored a month ago
440
                    }
441
                }
Bogdan Timofte authored a month ago
442
            } else if let chargingStateMode = chargedDevice.supportedChargingStateModes.first {
443
                Label(
444
                    "Charging mode: \(chargingStateMode.title)",
445
                    systemImage: chargingStateMode == .off ? "power.circle" : "power"
446
                )
447
                .font(.subheadline.weight(.semibold))
448
            }
449

            
450
            VStack(alignment: .leading, spacing: 8) {
451
                Text("Initial Checkpoint")
452
                    .font(.subheadline.weight(.semibold))
453

            
454
                TextField("Battery %", text: $initialCheckpoint)
455
                    .keyboardType(.decimalPad)
Bogdan Timofte authored a month ago
456

            
Bogdan Timofte authored a month ago
457
                Text("The session starts only after this first checkpoint is recorded.")
458
                    .font(.caption2)
459
                    .foregroundColor(.secondary)
460
            }
461

            
462
            Toggle("Auto-stop when the type already has a stop threshold", isOn: $draftAutoStopEnabled)
463

            
464
            Text(draftAutoStopDescription)
465
                .font(.footnote)
466
                .foregroundColor(setupWirelessThresholdWarning == nil ? .secondary : .orange)
467

            
468
            Button("Start Session") {
469
                startSession()
470
            }
471
            .frame(maxWidth: .infinity)
472
            .padding(.vertical, 10)
473
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
474
            .buttonStyle(.plain)
475
            .disabled(!canStartSession)
476
        }
477
    }
478

            
479
    private var wirelessChargerSection: some View {
480
        VStack(alignment: .leading, spacing: 10) {
481
            HStack {
482
                Text("Wireless Charger")
483
                    .font(.subheadline.weight(.semibold))
484
                Spacer()
485
                Button(selectedCharger == nil ? "Select" : "Change") {
486
                    chargerLibraryVisibility = true
Bogdan Timofte authored a month ago
487
                }
Bogdan Timofte authored a month ago
488
                .disabled(openChargeSession != nil)
489
            }
Bogdan Timofte authored a month ago
490

            
Bogdan Timofte authored a month ago
491
            if let selectedCharger {
492
                HStack(alignment: .top, spacing: 12) {
493
                    ChargedDeviceQRCodeView(
494
                        qrIdentifier: selectedCharger.qrIdentifier,
495
                        side: 62
496
                    )
497

            
498
                    VStack(alignment: .leading, spacing: 6) {
499
                        Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
500
                            .font(.subheadline.weight(.semibold))
501

            
502
                        if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
503
                            Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
504
                                .font(.caption)
505
                                .foregroundColor(.secondary)
506
                        }
507

            
508
                        if let chargerIdleCurrentAmps = selectedCharger.chargerIdleCurrentAmps {
509
                            Text("Idle current: \(chargerIdleCurrentAmps.format(decimalDigits: 2)) A")
510
                                .font(.caption2)
511
                                .foregroundColor(.secondary)
512
                        } else {
513
                            Text("Idle current is missing, so wireless stop-threshold learning and auto-stop stay unavailable.")
514
                                .font(.caption2)
515
                                .foregroundColor(.orange)
516
                        }
517
                    }
Bogdan Timofte authored a month ago
518
                }
519
            } else {
Bogdan Timofte authored a month ago
520
                Text("Wireless sessions need a selected charger in addition to the charged device.")
521
                    .font(.caption)
Bogdan Timofte authored a month ago
522
                    .foregroundColor(.secondary)
523
            }
524
        }
525
    }
526

            
Bogdan Timofte authored a month ago
527
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
528
        VStack(alignment: .leading, spacing: 12) {
529
            Text("Charging Monitor")
530
                .font(.headline)
531

            
532
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
533
                labels: ["Type", "Mode", "Energy", "Auto Stop"],
Bogdan Timofte authored a month ago
534
                values: [
Bogdan Timofte authored a month ago
535
                    openChargeSession.chargingTransportMode.title,
536
                    openChargeSession.chargingStateMode.title,
537
                    "\(openChargeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh",
538
                    autoStopLabel(for: openChargeSession)
Bogdan Timofte authored a month ago
539
                ]
540
            )
541

            
Bogdan Timofte authored a month ago
542
            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(for: openChargeSession) {
Bogdan Timofte authored a month ago
543
                VStack(alignment: .leading, spacing: 4) {
544
                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
545
                        .font(.caption.weight(.semibold))
546
                    Text(
547
                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
548
                    )
549
                    .font(.caption2)
550
                    .foregroundColor(.secondary)
551
                }
552
            }
553

            
Bogdan Timofte authored a month ago
554
            if let sessionWarning = sessionWarning(for: openChargeSession) {
555
                Text(sessionWarning)
556
                    .font(.caption)
557
                    .foregroundColor(.orange)
558
            }
559

            
560
            if openChargeSession.isPaused {
561
                Text("Paused at \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). If it stays paused for more than 10 minutes, it stops automatically.")
562
                    .font(.caption)
563
                    .foregroundColor(.secondary)
564
            }
565

            
566
            if openChargeSession.requiresCompletionConfirmation {
567
                completionConfirmationCard(openChargeSession)
568
            }
569

            
570
            if let targetBatteryPercent = openChargeSession.targetBatteryPercent {
Bogdan Timofte authored a month ago
571
                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
572
                    .font(.caption.weight(.semibold))
573
            } else {
574
                Text("No target battery notification configured.")
575
                    .font(.caption)
576
                    .foregroundColor(.secondary)
577
            }
578

            
Bogdan Timofte authored a month ago
579
            Button("Add Battery Checkpoint") {
580
                checkpointEditorVisibility = true
581
            }
582
            .frame(maxWidth: .infinity)
583
            .padding(.vertical, 10)
584
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
585
            .buttonStyle(.plain)
586

            
587
            Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
Bogdan Timofte authored a month ago
588
                targetNotificationEditorVisibility = true
589
            }
590
            .frame(maxWidth: .infinity)
591
            .padding(.vertical, 10)
592
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
593
            .buttonStyle(.plain)
594

            
Bogdan Timofte authored a month ago
595
            if openChargeSession.targetBatteryPercent != nil {
Bogdan Timofte authored a month ago
596
                Button("Clear Target Notification") {
Bogdan Timofte authored a month ago
597
                    _ = appData.setTargetBatteryPercent(nil, for: openChargeSession.id)
Bogdan Timofte authored a month ago
598
                }
599
                .frame(maxWidth: .infinity)
600
                .padding(.vertical, 10)
601
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
602
                .buttonStyle(.plain)
603
            }
604

            
Bogdan Timofte authored a month ago
605
            if openChargeSession.status == .active {
606
                Button("Pause Session") {
607
                    _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
608
                }
609
                .frame(maxWidth: .infinity)
610
                .padding(.vertical, 10)
611
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
612
                .buttonStyle(.plain)
613
            } else if openChargeSession.status == .paused {
614
                Button("Resume Session") {
615
                    _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
616
                }
617
                .frame(maxWidth: .infinity)
618
                .padding(.vertical, 10)
619
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
620
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
621
            }
622

            
Bogdan Timofte authored a month ago
623
            Button("Stop Session") {
624
                pendingStopRequest = ChargeSessionStopRequest(
625
                    sessionID: openChargeSession.id,
626
                    title: "Stop Session",
627
                    confirmTitle: "Stop",
628
                    explanation: "Record the final battery checkpoint before closing this session."
629
                )
Bogdan Timofte authored a month ago
630
            }
Bogdan Timofte authored a month ago
631
            .frame(maxWidth: .infinity)
632
            .padding(.vertical, 10)
633
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
634
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
635

            
Bogdan Timofte authored a month ago
636
            if !openChargeSession.checkpoints.isEmpty {
Bogdan Timofte authored a month ago
637
                VStack(alignment: .leading, spacing: 8) {
638
                    Text("Battery Checkpoints")
639
                        .font(.subheadline.weight(.semibold))
640

            
Bogdan Timofte authored a month ago
641
                    ForEach(openChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
Bogdan Timofte authored a month ago
642
                        HStack {
643
                            Text(checkpoint.timestamp.format())
644
                                .font(.caption2)
645
                                .foregroundColor(.secondary)
646
                            Spacer()
647
                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
648
                                .font(.caption.weight(.semibold))
649
                            Text("•")
650
                                .foregroundColor(.secondary)
651
                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
652
                                .font(.caption2)
653
                                .foregroundColor(.secondary)
654
                        }
655
                    }
656
                }
657
            }
658

            
Bogdan Timofte authored a month ago
659
            Text("The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own.")
Bogdan Timofte authored a month ago
660
                .font(.footnote)
661
                .foregroundColor(.secondary)
662
        }
663
        .padding(18)
664
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
665
    }
666

            
Bogdan Timofte authored a month ago
667
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
668
        VStack(alignment: .leading, spacing: 10) {
669
            Text("Completion Needs Confirmation")
670
                .font(.subheadline.weight(.semibold))
671

            
Bogdan Timofte authored a month ago
672
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
673
                Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
Bogdan Timofte authored a month ago
674
                    .font(.caption)
675
                    .foregroundColor(.secondary)
676
            } else {
Bogdan Timofte authored a month ago
677
                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
678
                    .font(.caption)
679
                    .foregroundColor(.secondary)
680
            }
681

            
Bogdan Timofte authored a month ago
682
            Button("Finish Session With Final Checkpoint") {
683
                pendingStopRequest = ChargeSessionStopRequest(
684
                    sessionID: openChargeSession.id,
685
                    title: "Finish Session",
686
                    confirmTitle: "Finish",
687
                    explanation: "Add the final checkpoint before confirming the stop."
688
                )
Bogdan Timofte authored a month ago
689
            }
690
            .frame(maxWidth: .infinity)
691
            .padding(.vertical, 10)
692
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
693
            .buttonStyle(.plain)
694

            
695
            Button("Keep Monitoring") {
Bogdan Timofte authored a month ago
696
                _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
697
            }
698
            .frame(maxWidth: .infinity)
699
            .padding(.vertical, 10)
700
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
701
            .buttonStyle(.plain)
702
        }
703
        .padding(14)
704
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
705
    }
706

            
Bogdan Timofte authored a month ago
707
    private func sessionChartCard(
708
        timeRange: ClosedRange<Date>,
709
        session: ChargeSessionSummary
710
    ) -> some View {
711
        VStack(alignment: .leading, spacing: 12) {
712
            Text("Session Chart")
713
                .font(.headline)
714

            
715
            MeasurementChartView(timeRange: timeRange)
716
                .environmentObject(usbMeter.measurements)
717
                .frame(minHeight: 220)
718

            
719
            Text("The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging.")
720
                .font(.footnote)
721
                .foregroundColor(.secondary)
722
        }
723
        .padding(18)
724
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
725
    }
726

            
727
    private var meterTotalsCard: some View {
728
        VStack(alignment: .leading, spacing: 12) {
729
            Text("Meter Totals")
730
                .font(.headline)
731

            
732
            ChargeRecordMetricsTableView(
733
                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
734
                values: [
735
                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
736
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
737
                    usbMeter.recordingDurationDescription,
738
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
739
                ]
740
            )
741

            
742
            Text("These values come directly from the meter and remain separate from the explicit app session controls.")
743
                .font(.footnote)
744
                .foregroundColor(.secondary)
745

            
746
            if usbMeter.supportsDataGroupCommands {
747
                Button("Reset Active Group") {
748
                    usbMeter.clear()
749
                }
750
                .frame(maxWidth: .infinity)
751
                .padding(.vertical, 10)
752
                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
753
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
754
            }
Bogdan Timofte authored a month ago
755
        }
756
        .padding(18)
757
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
758
    }
759

            
760
    private var showsWirelessChargerSection: Bool {
761
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
762
        return transportMode == .wireless
763
    }
764

            
765
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
766
        if session.autoStopEnabled == false {
767
            return "Manual"
768
        }
769
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
770
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
771
        }
772
        if session.stopThresholdAmps > 0 {
773
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
774
        }
775
        return "Learning"
776
    }
777

            
778
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
779
        guard session.chargingTransportMode == .wireless,
780
              let chargerID = session.chargerID,
781
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
782
            return nil
783
        }
784

            
785
        guard charger.chargerIdleCurrentAmps == nil else {
786
            return nil
787
        }
788

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

            
792
    private func startSession() {
793
        guard let selectedChargedDevice,
794
              let chargingTransportMode = selectedDraftTransportMode,
795
              let chargingStateMode = selectedDraftChargingStateMode,
796
              let initialCheckpointValue else {
797
            return
798
        }
799

            
800
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
801
        let didStart = appData.startChargeSession(
802
            for: usbMeter,
803
            chargedDeviceID: selectedChargedDevice.id,
804
            chargerID: chargerID,
805
            chargingTransportMode: chargingTransportMode,
806
            chargingStateMode: chargingStateMode,
807
            autoStopEnabled: draftAutoStopEnabled,
808
            initialBatteryPercent: initialCheckpointValue
Bogdan Timofte authored a month ago
809
        )
Bogdan Timofte authored a month ago
810

            
811
        if didStart {
812
            initialCheckpoint = ""
813
        }
Bogdan Timofte authored a month ago
814
    }
815

            
Bogdan Timofte authored a month ago
816
    private func syncDraftSelections() {
817
        guard let selectedChargedDevice else {
818
            draftChargingTransportMode = nil
819
            draftChargingStateMode = nil
820
            draftAutoStopEnabled = true
821
            return
822
        }
823

            
824
        if let openChargeSession {
825
            draftChargingTransportMode = openChargeSession.chargingTransportMode
826
            draftChargingStateMode = openChargeSession.chargingStateMode
827
            draftAutoStopEnabled = openChargeSession.autoStopEnabled
828
            return
829
        }
830

            
831
        if let draftChargingTransportMode,
832
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
833
            self.draftChargingTransportMode = nil
834
        }
835

            
836
        if let draftChargingStateMode,
837
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
838
            self.draftChargingStateMode = nil
839
        }
840

            
841
        if selectedChargedDevice.supportedChargingModes.count == 1 {
842
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
843
        }
844

            
845
        if selectedChargedDevice.supportedChargingStateModes.count == 1 {
846
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
847
        }
Bogdan Timofte authored a month ago
848
    }
Bogdan Timofte authored a month ago
849
}
850

            
851
struct ChargeSessionCompletionSheetView: View {
852
    @EnvironmentObject private var appData: AppData
853
    @Environment(\.dismiss) private var dismiss
854

            
855
    let sessionID: UUID
856
    let title: String
857
    let confirmTitle: String
858
    let explanation: String
859

            
860
    @State private var batteryPercent = ""
861
    @State private var label = "Final"
862

            
863
    var body: some View {
864
        NavigationView {
865
            Form {
866
                Section(header: Text("Final Checkpoint")) {
867
                    TextField("Battery %", text: $batteryPercent)
868
                        .keyboardType(.decimalPad)
869
                    TextField("Label", text: $label)
870
                }
871

            
872
                Section {
873
                    Text(explanation)
874
                        .font(.footnote)
875
                        .foregroundColor(.secondary)
876

            
877
                    if let sessionWarning {
878
                        Text(sessionWarning)
879
                            .font(.footnote)
880
                            .foregroundColor(.orange)
881
                    } else if (parsedBatteryPercent ?? 0) >= 99.5 {
882
                        Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
883
                            .font(.footnote)
884
                            .foregroundColor(.secondary)
885
                    }
886
                }
887
            }
888
            .navigationTitle(title)
889
            .navigationBarTitleDisplayMode(.inline)
890
            .toolbar {
891
                ToolbarItem(placement: .cancellationAction) {
892
                    Button("Cancel") {
893
                        dismiss()
894
                    }
895
                }
896
                ToolbarItem(placement: .confirmationAction) {
897
                    Button(confirmTitle) {
898
                        guard let batteryPercent = parsedBatteryPercent else {
899
                            return
900
                        }
901

            
902
                        if appData.stopChargeSession(
903
                            sessionID: sessionID,
904
                            finalBatteryPercent: batteryPercent,
905
                            label: label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Final" : label
906
                        ) {
907
                            dismiss()
908
                        }
909
                    }
910
                    .disabled(parsedBatteryPercent == nil)
911
                }
912
            }
913
        }
914
        .navigationViewStyle(StackNavigationViewStyle())
915
    }
916

            
917
    private var parsedBatteryPercent: Double? {
918
        let normalized = batteryPercent
919
            .trimmingCharacters(in: .whitespacesAndNewlines)
920
            .replacingOccurrences(of: ",", with: ".")
921
        guard let value = Double(normalized), value >= 0, value <= 100 else {
922
            return nil
923
        }
924
        return value
925
    }
926

            
927
    private var sessionWarning: String? {
928
        guard let session = appData.chargedDevices
929
            .flatMap(\.sessions)
930
            .first(where: { $0.id == sessionID }),
931
              session.chargingTransportMode == .wireless,
932
              let chargerID = session.chargerID,
933
              let charger = appData.chargedDeviceSummary(id: chargerID),
934
              charger.chargerIdleCurrentAmps == nil else {
935
            return nil
936
        }
937

            
938
        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."
939
    }
940
}
941

            
942
private struct ChargeSessionStopRequest: Identifiable {
943
    let sessionID: UUID
944
    let title: String
945
    let confirmTitle: String
946
    let explanation: String
Bogdan Timofte authored a month ago
947

            
Bogdan Timofte authored a month ago
948
    var id: UUID {
949
        sessionID
Bogdan Timofte authored a month ago
950
    }
951
}