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

            
6
import SwiftUI
7

            
8
struct MeterChargeRecordTabView: View {
9
    @EnvironmentObject private var appData: AppData
10
    @EnvironmentObject private var usbMeter: Meter
11
    @State private var chargedDeviceLibraryVisibility = false
12
    @State private var chargerLibraryVisibility = false
13
    @State private var checkpointEditorVisibility = false
14
    @State private var editingChargedDevice: ChargedDeviceSummary?
15
    @State private var targetNotificationEditorVisibility = false
16

            
17
    var body: some View {
18
        ScrollView {
19
            VStack(spacing: 16) {
20
                VStack(alignment: .leading, spacing: 8) {
21
                    HStack {
22
                        Text("Charge Record")
23
                            .font(.system(.title3, design: .rounded).weight(.bold))
24
                        Spacer()
25
                        Text(usbMeter.chargeRecordStatusText)
26
                            .font(.caption.weight(.bold))
27
                            .foregroundColor(usbMeter.chargeRecordStatusColor)
28
                            .padding(.horizontal, 10)
29
                            .padding(.vertical, 6)
30
                            .meterCard(
31
                                tint: usbMeter.chargeRecordStatusColor,
32
                                fillOpacity: 0.18,
33
                                strokeOpacity: 0.24,
34
                                cornerRadius: 999
35
                            )
36
                    }
37
                    Text("App-side charge accumulation based on the stop-threshold workflow.")
38
                        .font(.footnote)
39
                        .foregroundColor(.secondary)
40
                }
41
                .frame(maxWidth: .infinity)
42
                .padding(18)
43
                .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
44

            
45
                chargedDeviceSection
46

            
47
                if let activeChargeSession {
48
                    chargeMonitorSection(activeChargeSession)
49
                }
50

            
51
                ChargeRecordMetricsTableView(
52
                    labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
53
                    values: [
54
                        "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
55
                        "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
56
                        usbMeter.chargeRecordDurationDescription,
57
                        "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
58
                    ]
59
                )
60
                .padding(18)
61
                .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
62

            
63
                if usbMeter.chargeRecordTimeRange != nil {
64
                    VStack(alignment: .leading, spacing: 12) {
65
                        HStack {
66
                            Text("Charge Curve")
67
                                .font(.headline)
68
                            Spacer()
69
                            Button("Reset Graph") {
70
                                usbMeter.resetChargeRecordGraph()
71
                            }
72
                            .foregroundColor(.red)
73
                        }
74
                        MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
75
                            .environmentObject(usbMeter.measurements)
76
                            .frame(minHeight: 220)
77
                        Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
78
                            .font(.footnote)
79
                            .foregroundColor(.secondary)
80
                    }
81
                    .padding(18)
82
                    .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
83
                }
84

            
85
                VStack(alignment: .leading, spacing: 12) {
86
                    Text("Stop Threshold")
87
                        .font(.headline)
88
                    Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
89
                    Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
90
                        .font(.footnote)
91
                        .foregroundColor(.secondary)
92
                    Button("Reset") {
93
                        usbMeter.resetChargeRecord()
94
                    }
95
                    .frame(maxWidth: .infinity)
96
                    .padding(.vertical, 10)
97
                    .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
98
                    .buttonStyle(.plain)
99
                }
100
                .padding(18)
101
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
102

            
103
                if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
104
                    VStack(alignment: .leading, spacing: 12) {
105
                        Text("Meter Totals")
106
                            .font(.headline)
107
                        ChargeRecordMetricsTableView(
108
                            labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
109
                            values: [
110
                                "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
111
                                "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
112
                                usbMeter.recordingDurationDescription,
113
                                usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
114
                            ]
115
                        )
116
                        Text("These values are reported by the meter for the active data group.")
117
                            .font(.footnote)
118
                            .foregroundColor(.secondary)
119
                        if usbMeter.supportsDataGroupCommands {
120
                            Button("Reset Active Group") {
121
                                usbMeter.clear()
122
                            }
123
                            .frame(maxWidth: .infinity)
124
                            .padding(.vertical, 10)
125
                            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
126
                            .buttonStyle(.plain)
127
                        }
128
                    }
129
                    .padding(18)
130
                    .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
131
                }
132
            }
133
            .padding()
134
        }
135
        .background(
136
            LinearGradient(
137
                colors: [.pink.opacity(0.14), Color.clear],
138
                startPoint: .topLeading,
139
                endPoint: .bottomTrailing
140
            )
141
            .ignoresSafeArea()
142
        )
143
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
144
            ChargedDeviceLibrarySheetView(
145
                visibility: $chargedDeviceLibraryVisibility,
146
                meterMACAddress: usbMeter.btSerial.macAddress.description,
147
                meterTint: usbMeter.color,
148
                mode: .device
149
            )
150
            .environmentObject(appData)
151
        }
152
        .sheet(isPresented: $chargerLibraryVisibility) {
153
            ChargedDeviceLibrarySheetView(
154
                visibility: $chargerLibraryVisibility,
155
                meterMACAddress: usbMeter.btSerial.macAddress.description,
156
                meterTint: usbMeter.color,
157
                mode: .charger
158
            )
159
            .environmentObject(appData)
160
        }
161
        .sheet(isPresented: $checkpointEditorVisibility) {
162
            BatteryCheckpointEditorSheetView()
163
                .environmentObject(appData)
164
                .environmentObject(usbMeter)
165
        }
166
        .sheet(item: $editingChargedDevice) { chargedDevice in
167
            ChargedDeviceEditorSheetView(
168
                meterMACAddress: nil,
169
                chargedDevice: chargedDevice
170
            )
171
            .environmentObject(appData)
172
        }
173
        .sheet(isPresented: $targetNotificationEditorVisibility) {
174
            if let activeChargeSession {
175
                BatteryTargetNotificationEditorSheetView(
176
                    sessionID: activeChargeSession.id,
177
                    initialTargetPercent: activeChargeSession.targetBatteryPercent
178
                )
179
                .environmentObject(appData)
180
            }
181
        }
182
    }
183

            
184
    private var selectedChargedDevice: ChargedDeviceSummary? {
185
        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
186
    }
187

            
188
    private var activeChargeSession: ChargeSessionSummary? {
189
        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
190
    }
191

            
192
    private var selectedCharger: ChargedDeviceSummary? {
193
        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
194
    }
195

            
196
    private var chargedDeviceSection: some View {
197
        VStack(alignment: .leading, spacing: 12) {
198
            HStack {
199
                Text("Device")
200
                    .font(.headline)
201
                Spacer()
202
                Button("Library") {
203
                    chargedDeviceLibraryVisibility = true
204
                }
205
            }
206

            
207
            if let selectedChargedDevice {
208
                HStack(alignment: .top, spacing: 14) {
209
                    ChargedDeviceQRCodeView(
210
                        qrIdentifier: selectedChargedDevice.qrIdentifier,
211
                        side: 88
212
                    )
213

            
214
                    VStack(alignment: .leading, spacing: 8) {
215
                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
216
                            .font(.headline)
217

            
218
                        Text(selectedChargedDevice.deviceClass.title)
219
                            .font(.caption.weight(.semibold))
220
                            .foregroundColor(.secondary)
221

            
222
                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
223
                            .font(.caption2)
224
                            .foregroundColor(.secondary)
225

            
226
                        if selectedChargedDevice.supportedChargingModes.count == 1 {
227
                            Label(
228
                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
229
                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
230
                            )
231
                            .font(.caption2)
232
                            .foregroundColor(.secondary)
233
                        } else {
234
                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
235
                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
236
                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
237
                                        .tag(chargingTransportMode)
238
                                }
239
                            }
240
                            .pickerStyle(.segmented)
241
                        }
242

            
243
                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
244
                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
245
                                .font(.caption)
246
                                .foregroundColor(.secondary)
247
                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
248
                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
249
                                .font(.caption)
250
                                .foregroundColor(.secondary)
251
                        }
252

            
253
                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
254
                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
255
                                .font(.caption2)
256
                                .foregroundColor(.secondary)
257
                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
258
                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
259
                                .font(.caption2)
260
                                .foregroundColor(.secondary)
261
                        }
262
                    }
263

            
264
                    Spacer(minLength: 0)
265
                }
266

            
267
                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
268
                    Divider()
269

            
270
                    VStack(alignment: .leading, spacing: 10) {
271
                        HStack {
272
                            Text("Wireless Charger")
273
                                .font(.subheadline.weight(.semibold))
274
                            Spacer()
275
                            Button(selectedCharger == nil ? "Select" : "Change") {
276
                                chargerLibraryVisibility = true
277
                            }
278
                        }
279

            
280
                        if let selectedCharger {
281
                            HStack(alignment: .top, spacing: 12) {
282
                                ChargedDeviceQRCodeView(
283
                                    qrIdentifier: selectedCharger.qrIdentifier,
284
                                    side: 62
285
                                )
286

            
287
                                VStack(alignment: .leading, spacing: 6) {
288
                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
289
                                        .font(.subheadline.weight(.semibold))
290

            
291
                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
292
                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
293
                                            .font(.caption)
294
                                            .foregroundColor(.secondary)
295
                                    }
296

            
297
                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
298
                                        Text(
299
                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
300
                                                .map { "\($0.format(decimalDigits: 1)) V" }
301
                                                .joined(separator: ", ")
302
                                        )
303
                                        .font(.caption2)
304
                                        .foregroundColor(.secondary)
305
                                    }
306
                                }
307
                            }
308
                        } else {
309
                            Text("Wireless sessions need a selected charger in addition to the charged device.")
310
                                .font(.caption)
311
                                .foregroundColor(.secondary)
312
                        }
313
                    }
314
                }
315

            
316
                Button("Add Battery Checkpoint") {
317
                    checkpointEditorVisibility = true
318
                }
319
                .frame(maxWidth: .infinity)
320
                .padding(.vertical, 10)
321
                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
322
                .buttonStyle(.plain)
323

            
324
                Button("Edit Device") {
325
                    editingChargedDevice = selectedChargedDevice
326
                }
327
                .frame(maxWidth: .infinity)
328
                .padding(.vertical, 10)
329
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
330
                .buttonStyle(.plain)
331
            } else {
332
                Text("Select or create the device you are charging. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.")
333
                    .font(.footnote)
334
                    .foregroundColor(.secondary)
335
            }
336
        }
337
        .padding(18)
338
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
339
    }
340

            
341
    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
342
        VStack(alignment: .leading, spacing: 12) {
343
            Text("Charging Monitor")
344
                .font(.headline)
345

            
346
            ChargeRecordMetricsTableView(
347
                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
348
                values: [
349
                    activeChargeSession.sourceMode.title,
350
                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
351
                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
352
                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
353
                ]
354
            )
355

            
356
            if let selectedChargedDevice,
357
               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
358
                VStack(alignment: .leading, spacing: 4) {
359
                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
360
                        .font(.caption.weight(.semibold))
361
                    Text(
362
                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
363
                    )
364
                    .font(.caption2)
365
                    .foregroundColor(.secondary)
366
                }
367
            }
368

            
369
            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
370
                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
371
                    .font(.caption.weight(.semibold))
372
            } else {
373
                Text("No target battery notification configured.")
374
                    .font(.caption)
375
                    .foregroundColor(.secondary)
376
            }
377

            
378
            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
379
                targetNotificationEditorVisibility = true
380
            }
381
            .frame(maxWidth: .infinity)
382
            .padding(.vertical, 10)
383
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
384
            .buttonStyle(.plain)
385

            
386
            if activeChargeSession.targetBatteryPercent != nil {
387
                Button("Clear Target Notification") {
388
                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
389
                }
390
                .frame(maxWidth: .infinity)
391
                .padding(.vertical, 10)
392
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
393
                .buttonStyle(.plain)
394
            }
395

            
396
            if activeChargeSession.requiresCompletionConfirmation {
397
                completionConfirmationCard(activeChargeSession)
398
            }
399

            
400
            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
401
                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
402
                    .font(.caption.weight(.semibold))
403
            }
404

            
405
            Label(
406
                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
407
                systemImage: activeChargeSession.chargingTransportMode.symbolName
408
            )
409
            .font(.caption)
410
            .foregroundColor(.secondary)
411

            
412
            if activeChargeSession.chargingTransportMode == .wireless {
413
                if let chargerID = activeChargeSession.chargerID,
414
                   let charger = appData.chargedDeviceSummary(id: chargerID) {
415
                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
416
                        .font(.caption)
417
                        .foregroundColor(.secondary)
418
                } else {
419
                    Text("No wireless charger is currently selected for this session.")
420
                        .font(.caption)
421
                        .foregroundColor(.orange)
422
                }
423
            }
424

            
425
            if activeChargeSession.checkpoints.isEmpty == false {
426
                VStack(alignment: .leading, spacing: 8) {
427
                    Text("Battery Checkpoints")
428
                        .font(.subheadline.weight(.semibold))
429

            
430
                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
431
                        HStack {
432
                            Text(checkpoint.timestamp.format())
433
                                .font(.caption2)
434
                                .foregroundColor(.secondary)
435
                            Spacer()
436
                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
437
                                .font(.caption.weight(.semibold))
438
                            Text("•")
439
                                .foregroundColor(.secondary)
440
                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
441
                                .font(.caption2)
442
                                .foregroundColor(.secondary)
443
                        }
444
                    }
445
                }
446
            }
447

            
448
            Text("The monitor prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.")
449
                .font(.footnote)
450
                .foregroundColor(.secondary)
451
        }
452
        .padding(18)
453
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
454
    }
455

            
456
    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
457
        VStack(alignment: .leading, spacing: 10) {
458
            Text("Completion Needs Confirmation")
459
                .font(.subheadline.weight(.semibold))
460

            
461
            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
462
                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
463
                    .font(.caption)
464
                    .foregroundColor(.secondary)
465
            } else {
466
                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
467
                    .font(.caption)
468
                    .foregroundColor(.secondary)
469
            }
470

            
471
            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
472
                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
473
                    .font(.caption2)
474
                    .foregroundColor(.secondary)
475
            }
476

            
477
            Button("Finish Session") {
478
                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
479
            }
480
            .frame(maxWidth: .infinity)
481
            .padding(.vertical, 10)
482
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
483
            .buttonStyle(.plain)
484

            
485
            Button("Keep Monitoring") {
486
                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
487
            }
488
            .frame(maxWidth: .infinity)
489
            .padding(.vertical, 10)
490
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
491
            .buttonStyle(.plain)
492
        }
493
        .padding(14)
494
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
495
    }
496

            
497
    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
498
        Binding(
499
            get: {
500
                effectiveChargingTransportMode(for: chargedDevice)
501
            },
502
            set: { newValue in
503
                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
504
            }
505
        )
506
    }
507

            
508
    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
509
        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
510
    }
511

            
512
    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
513
        effectiveChargingTransportMode(for: chargedDevice) == .wireless
514
    }
515
}