USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
912 lines | 38.584kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargedDeviceDetailView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
struct ChargedDeviceDetailView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @Environment(\.dismiss) private var dismiss
13
    @State private var editorVisibility = false
14
    @State private var checkpointEditorVisibility = false
15
    @State private var targetNotificationEditorVisibility = false
16
    @State private var pendingSessionDeletion: ChargeSessionSummary?
17
    @State private var deleteConfirmationVisibility = false
18

            
19
    let chargedDeviceID: UUID
20

            
21
    var body: some View {
22
        Group {
23
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
24
                ScrollView {
25
                    VStack(spacing: 18) {
26
                        headerCard(chargedDevice)
27
                        insightsCard(chargedDevice)
28

            
29
                        if let activeSession = chargedDevice.activeSession {
30
                            activeSessionCard(activeSession, chargedDevice: chargedDevice)
31
                        }
32

            
33
                        if let curveSession = preferredStoredCurveSession(for: chargedDevice) {
34
                            storedCurveCard(curveSession)
35
                        }
36

            
37
                        if !chargedDevice.capacityHistory.isEmpty {
38
                            capacityEvolutionCard(chargedDevice)
39
                        }
40

            
41
                        if !chargedDevice.typicalCurve.isEmpty {
42
                            typicalCurveCard(chargedDevice)
43
                        }
44

            
45
                        if !chargedDevice.sessions.isEmpty {
46
                            sessionsCard(chargedDevice)
47
                        }
48
                    }
49
                    .padding()
50
                }
51
                .background(
52
                    LinearGradient(
53
                        colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
54
                        startPoint: .topLeading,
55
                        endPoint: .bottomTrailing
56
                    )
57
                    .ignoresSafeArea()
58
                )
59
                .navigationTitle(chargedDevice.name)
60
                .toolbar {
61
                    ToolbarItemGroup(placement: .primaryAction) {
62
                        Button("Edit") {
63
                            editorVisibility = true
64
                        }
65
                        Button(role: .destructive) {
66
                            deleteConfirmationVisibility = true
67
                        } label: {
68
                            Image(systemName: "trash")
69
                        }
70
                    }
71
                }
72
            } else {
73
                Text("This device is no longer available.")
74
                    .foregroundColor(.secondary)
75
                    .navigationTitle("Device")
76
            }
77
        }
78
        .sheet(isPresented: $editorVisibility) {
79
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
80
                ChargedDeviceEditorSheetView(
81
                    meterMACAddress: nil,
82
                    chargedDevice: chargedDevice
83
                )
84
                .environmentObject(appData)
85
            }
86
        }
87
        .sheet(isPresented: $checkpointEditorVisibility) {
88
            if let sessionID = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id {
89
                ChargedDeviceCheckpointEditorSheetView(sessionID: sessionID)
90
                    .environmentObject(appData)
91
            }
92
        }
93
        .sheet(isPresented: $targetNotificationEditorVisibility) {
94
            if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
95
                ChargedDeviceTargetNotificationEditorSheetView(
96
                    sessionID: activeSession.id,
97
                    initialTargetPercent: activeSession.targetBatteryPercent
98
                )
99
                .environmentObject(appData)
100
            }
101
        }
102
        .alert(item: $pendingSessionDeletion) { session in
103
            Alert(
104
                title: Text("Delete Session?"),
105
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
106
                primaryButton: .destructive(Text("Delete")) {
107
                    _ = appData.deleteChargeSession(sessionID: session.id)
108
                },
109
                secondaryButton: .cancel()
110
            )
111
        }
112
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
113
            Button("Delete", role: .destructive) {
114
                if appData.deleteChargedDevice(id: chargedDeviceID) {
115
                    dismiss()
116
                }
117
            }
118
            Button("Cancel", role: .cancel) {}
119
        } message: {
120
            Text(deletionMessage)
121
        }
122
    }
123

            
124
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
125
        HStack(alignment: .top, spacing: 18) {
126
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
127

            
128
            VStack(alignment: .leading, spacing: 10) {
129
                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
130
                    .font(.title3.weight(.bold))
131

            
132
                Text(chargedDevice.deviceClass.title)
133
                    .font(.subheadline.weight(.semibold))
134
                    .foregroundColor(.secondary)
135

            
136
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
137
                    Text("Default meter: \(meterMAC)")
138
                        .font(.caption)
139
                        .foregroundColor(.secondary)
140
                }
141

            
142
                Text(chargedDevice.qrIdentifier)
143
                    .font(.caption2.monospaced())
144
                    .foregroundColor(.secondary)
145
                    .textSelection(.enabled)
146
            }
147

            
148
            Spacer(minLength: 0)
149
        }
150
        .frame(maxWidth: .infinity, alignment: .leading)
151
        .padding(18)
152
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
153
    }
154

            
155
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
156
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
157
            MeterInfoRowView(
158
                label: "Supports Charging While Off",
159
                value: chargedDevice.supportsChargingWhileOff ? "Yes" : "No"
160
            )
161
            MeterInfoRowView(
162
                label: "Charging Support",
163
                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
164
            )
165
            MeterInfoRowView(
166
                label: "Preferred Session Type",
167
                value: chargedDevice.preferredChargingTransportMode.title
168
            )
169
            if chargedDevice.supportsWirelessCharging {
170
                MeterInfoRowView(
171
                    label: "Wireless Profile",
172
                    value: chargedDevice.wirelessChargingProfile.title
173
                )
174
            }
175
            if chargedDevice.supportsWiredCharging {
176
                MeterInfoRowView(
177
                    label: "Wired Completion Current",
178
                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wired)
179
                )
180
            }
181
            if chargedDevice.supportsWirelessCharging {
182
                MeterInfoRowView(
183
                    label: "Wireless Completion Current",
184
                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wireless)
185
                )
186
            }
187
            MeterInfoRowView(
188
                label: "Estimated Capacity",
189
                value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
190
            )
191
            if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
192
                MeterInfoRowView(
193
                    label: "Wired Capacity",
194
                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
195
                )
196
            }
197
            if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
198
                MeterInfoRowView(
199
                    label: "Wireless Capacity",
200
                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
201
                )
202
            }
203
            if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
204
                MeterInfoRowView(
205
                    label: "Wireless Efficiency",
206
                    value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
207
                )
208
            }
209
            if chargedDevice.isCharger {
210
                Divider()
211
                if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
212
                    MeterInfoRowView(
213
                        label: "Observed Voltages",
214
                        value: chargedDevice.chargerObservedVoltageSelections
215
                            .map { "\($0.format(decimalDigits: 1)) V" }
216
                            .joined(separator: ", ")
217
                    )
218
                }
219
                if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
220
                    MeterInfoRowView(
221
                        label: "Idle Current",
222
                        value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
223
                    )
224
                }
225
                if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
226
                    MeterInfoRowView(
227
                        label: "Efficiency",
228
                        value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
229
                    )
230
                }
231
                if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
232
                    MeterInfoRowView(
233
                        label: "Max Power",
234
                        value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
235
                    )
236
                }
237
            }
238
            MeterInfoRowView(
239
                label: "End-of-Charge Current",
240
                value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
241
            )
242
            MeterInfoRowView(
243
                label: "Charge Sessions",
244
                value: "\(chargedDevice.sessionCount)"
245
            )
246

            
247
            if let notes = chargedDevice.notes, !notes.isEmpty {
248
                Divider()
249
                Text(notes)
250
                    .font(.footnote)
251
                    .foregroundColor(.secondary)
252
                    .frame(maxWidth: .infinity, alignment: .leading)
253
            }
254
        }
255
    }
256

            
257
    private func activeSessionCard(
258
        _ activeSession: ChargeSessionSummary,
259
        chargedDevice: ChargedDeviceSummary
260
    ) -> some View {
261
        MeterInfoCardView(title: "Active Session", tint: .green) {
262
            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
263
            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
264
            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
265
            if activeSession.chargingTransportMode == .wireless,
266
               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
267
               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
268
                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
269
            }
270
            MeterInfoRowView(label: "Charge", value: "\(activeSession.measuredChargeAh.format(decimalDigits: 3)) Ah")
271
            MeterInfoRowView(label: "Stop Threshold", value: "\(activeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
272
            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
273
            if chargedDevice.isCharger == false,
274
               let chargerID = activeSession.chargerID,
275
               let charger = appData.chargedDeviceSummary(id: chargerID) {
276
                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
277
            }
278
            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
279
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
280
            }
281
            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
282
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
283
            }
284
            if activeSession.chargingTransportMode == .wired,
285
               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
286
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
287
            }
288
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
289
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
290
            }
291
            if let targetBatteryPercent = activeSession.targetBatteryPercent {
292
                MeterInfoRowView(
293
                    label: "Target Notification",
294
                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
295
                )
296
            }
297
            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
298
                Text(wirelessSessionHint)
299
                    .font(.caption2)
300
                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
301
            }
302
            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
303
                MeterInfoRowView(
304
                    label: "Predicted Battery",
305
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
306
                )
307
                Text(
308
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
309
                )
310
                .font(.caption2)
311
                .foregroundColor(.secondary)
312
            }
313

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

            
322
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
323
                targetNotificationEditorVisibility = true
324
            }
325
            .frame(maxWidth: .infinity)
326
            .padding(.vertical, 10)
327
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
328
            .buttonStyle(.plain)
329

            
330
            if activeSession.targetBatteryPercent != nil {
331
                Button("Clear Target Notification") {
332
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
333
                }
334
                .frame(maxWidth: .infinity)
335
                .padding(.vertical, 10)
336
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
337
                .buttonStyle(.plain)
338
            }
339

            
340
            Button(activeSession.requiresCompletionConfirmation ? "Finish Session" : "End Session") {
341
                _ = appData.confirmChargeSessionCompletion(sessionID: activeSession.id)
342
            }
343
            .frame(maxWidth: .infinity)
344
            .padding(.vertical, 10)
345
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
346
            .buttonStyle(.plain)
347

            
348
            if activeSession.requiresCompletionConfirmation {
349
                Divider()
350
                if let contradictionPercent = activeSession.completionContradictionPercent {
351
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
352
                        .font(.caption2)
353
                        .foregroundColor(.secondary)
354
                }
355

            
356
                Button("Keep Monitoring") {
357
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
358
                }
359
                .frame(maxWidth: .infinity)
360
                .padding(.vertical, 10)
361
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
362
                .buttonStyle(.plain)
363
            }
364
        }
365
    }
366

            
367
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
368
        VStack(alignment: .leading, spacing: 12) {
369
            Text("Capacity Evolution")
370
                .font(.headline)
371

            
372
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
373
                HStack {
374
                    Text(point.timestamp.format())
375
                        .font(.caption)
376
                        .foregroundColor(.secondary)
377
                    Spacer()
378
                    Text(point.chargingTransportMode.title)
379
                        .font(.caption2)
380
                        .foregroundColor(.secondary)
381
                    Text("•")
382
                        .foregroundColor(.secondary)
383
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
384
                        .font(.footnote.weight(.semibold))
385
                }
386
            }
387
        }
388
        .frame(maxWidth: .infinity, alignment: .leading)
389
        .padding(18)
390
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
391
    }
392

            
393
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
394
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
395
            return activeSession
396
        }
397

            
398
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
399
    }
400

            
401
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
402
        let currentSeries = storedSeriesSnapshot(
403
            from: session.aggregatedSamples,
404
            minimumYSpan: 0.15
405
        ) { $0.averageCurrentAmps }
406
        let energySeries = storedSeriesSnapshot(
407
            from: session.aggregatedSamples,
408
            minimumYSpan: 0.2
409
        ) { $0.measuredEnergyWh }
410

            
411
        return VStack(alignment: .leading, spacing: 14) {
412
            HStack(alignment: .firstTextBaseline) {
413
                VStack(alignment: .leading, spacing: 4) {
414
                    Text("Stored Session Curve")
415
                        .font(.headline)
416
                    Text(session.status == .active ? "Active session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
417
                        .font(.caption)
418
                        .foregroundColor(.secondary)
419
                }
420

            
421
                Spacer()
422

            
423
                Text("\(session.aggregatedSamples.count) points")
424
                    .font(.caption.weight(.semibold))
425
                    .foregroundColor(.secondary)
426
            }
427

            
428
            if let currentSeries {
429
                storedSeriesChart(
430
                    title: "Current",
431
                    unit: "A",
432
                    strokeColor: .blue,
433
                    snapshot: currentSeries
434
                )
435
            }
436

            
437
            if let energySeries {
438
                storedSeriesChart(
439
                    title: "Energy",
440
                    unit: "Wh",
441
                    strokeColor: .teal,
442
                    areaChart: true,
443
                    snapshot: energySeries
444
                )
445
            }
446

            
447
            Text("Database storage and iCloud sync use 300 aggregated points per hour. The live recording session still keeps the original in-memory samples while charging is in progress.")
448
                .font(.caption)
449
                .foregroundColor(.secondary)
450
        }
451
        .frame(maxWidth: .infinity, alignment: .leading)
452
        .padding(18)
453
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
454
    }
455

            
456
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
457
        VStack(alignment: .leading, spacing: 12) {
458
            Text("Typical Charge Curve")
459
                .font(.headline)
460

            
461
            ForEach(chargedDevice.typicalCurve) { point in
462
                HStack {
463
                    Text("\(point.percentBin)%")
464
                        .font(.footnote.weight(.semibold))
465
                    Spacer()
466
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
467
                        .font(.caption.weight(.semibold))
468
                    Text("•")
469
                        .foregroundColor(.secondary)
470
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
471
                        .font(.caption2)
472
                        .foregroundColor(.secondary)
473
                }
474
            }
475
        }
476
        .frame(maxWidth: .infinity, alignment: .leading)
477
        .padding(18)
478
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
479
    }
480

            
481
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
482
        VStack(alignment: .leading, spacing: 12) {
483
            Text("Charge Sessions")
484
                .font(.headline)
485

            
486
            Text("Use these summaries to spot odd sessions quickly before they influence device estimates.")
487
                .font(.caption)
488
                .foregroundColor(.secondary)
489

            
490
            ForEach(chargedDevice.sessions, id: \.id) { session in
491
                VStack(alignment: .leading, spacing: 6) {
492
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
493
                        Text(session.startedAt.format())
494
                            .font(.caption.weight(.semibold))
495
                        Text(session.status.title)
496
                            .font(.caption2.weight(.semibold))
497
                            .padding(.horizontal, 8)
498
                            .padding(.vertical, 4)
499
                            .background(
500
                                Capsule()
501
                                    .fill(statusTint(for: session).opacity(0.16))
502
                            )
503
                        Spacer()
504
                        Button {
505
                            pendingSessionDeletion = session
506
                        } label: {
507
                            Image(systemName: "trash")
508
                                .font(.caption.weight(.semibold))
509
                                .foregroundColor(.red)
510
                                .padding(8)
511
                                .background(
512
                                    Circle()
513
                                        .fill(Color.red.opacity(0.10))
514
                                )
515
                        }
516
                        .buttonStyle(.plain)
517
                    }
518

            
519
                    Text(sessionSummaryLine(session))
520
                        .font(.caption2)
521
                        .foregroundColor(.secondary)
522

            
523
                    MeterInfoRowView(
524
                        label: "Duration",
525
                        value: sessionDurationText(session)
526
                    )
527
                    MeterInfoRowView(
528
                        label: "Energy",
529
                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
530
                    )
531
                    if session.chargingTransportMode == .wireless,
532
                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
533
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
534
                        MeterInfoRowView(
535
                            label: "Charger Energy",
536
                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
537
                        )
538
                    }
539
                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
540
                        MeterInfoRowView(
541
                            label: "Max Current",
542
                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
543
                        )
544
                    }
545
                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
546
                        MeterInfoRowView(
547
                            label: "Max Power",
548
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
549
                        )
550
                    }
551
                    if session.chargingTransportMode == .wired,
552
                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
553
                        MeterInfoRowView(
554
                            label: "Max Voltage",
555
                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
556
                        )
557
                    }
558
                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
559
                        MeterInfoRowView(
560
                            label: "Selected Voltage",
561
                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
562
                        )
563
                    }
564
                    if let selectedDataGroup = session.selectedDataGroup {
565
                        MeterInfoRowView(
566
                            label: "Data Group",
567
                            value: "#\(selectedDataGroup)"
568
                        )
569
                    }
570
                    if chargedDevice.isCharger == false,
571
                       let chargerID = session.chargerID,
572
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
573
                        MeterInfoRowView(
574
                            label: "Wireless Charger",
575
                            value: charger.name
576
                        )
577
                    }
578
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
579
                        Text(wirelessSessionHint)
580
                            .font(.caption2)
581
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
582
                    }
583
                }
584
                .padding(14)
585
                .meterCard(
586
                    tint: statusTint(for: session),
587
                    fillOpacity: 0.10,
588
                    strokeOpacity: 0.16,
589
                    cornerRadius: 16
590
                )
591
            }
592
        }
593
        .frame(maxWidth: .infinity, alignment: .leading)
594
        .padding(18)
595
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
596
    }
597

            
598
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
599
        var components: [String] = []
600

            
601
        if let batteryDeltaPercent = session.batteryDeltaPercent {
602
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
603
        }
604

            
605
        if let capacityEstimateWh = session.capacityEstimateWh {
606
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
607
        }
608

            
609
        components.append(session.chargingTransportMode.title)
610
        components.append(session.sourceMode.title)
611
        return components.joined(separator: " • ")
612
    }
613

            
614
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
615
        guard session.chargingTransportMode == .wireless else {
616
            return nil
617
        }
618

            
619
        var components: [String] = []
620
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
621
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
622
        }
623
        if session.usesEstimatedWirelessEfficiency {
624
            components.append("Estimated from wired baseline and checkpoints")
625
        }
626
        if session.shouldWarnAboutLowWirelessEfficiency {
627
            components.append("Low wireless efficiency, so capacity confidence is reduced")
628
        }
629

            
630
        return components.isEmpty ? nil : components.joined(separator: " • ")
631
    }
632

            
633
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
634
        let formatter = DateComponentsFormatter()
635
        formatter.allowedUnits = session.duration >= 3600 ? [.hour, .minute] : [.minute, .second]
636
        formatter.unitsStyle = .abbreviated
637
        formatter.zeroFormattingBehavior = .dropAll
638
        return formatter.string(from: max(session.duration, 0)) ?? "0m"
639
    }
640

            
641
    private func statusTint(for session: ChargeSessionSummary) -> Color {
642
        switch session.status {
643
        case .active:
644
            return .green
645
        case .completed:
646
            return .teal
647
        case .abandoned:
648
            return .orange
649
        }
650
    }
651

            
652
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
653
        switch chargedDevice.deviceClass {
654
        case .iphone:
655
            return .blue
656
        case .watch:
657
            return .green
658
        case .powerbank:
659
            return .orange
660
        case .charger:
661
            return .pink
662
        case .other:
663
            return .secondary
664
        }
665
    }
666

            
667
    private func completionCurrentDescription(
668
        for chargedDevice: ChargedDeviceSummary,
669
        chargingTransportMode: ChargingTransportMode
670
    ) -> String {
671
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: chargingTransportMode) {
672
            if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode),
673
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
674
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
675
            }
676
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
677
        }
678

            
679
        if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode) {
680
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
681
        }
682

            
683
        return "Learning"
684
    }
685

            
686
    private var deletionTitle: String {
687
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
688
    }
689

            
690
    private var deletionMessage: String {
691
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
692
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
693
        }
694
        return "This removes the device and its stored charging history from the library."
695
    }
696

            
697
    private func storedSeriesSnapshot(
698
        from samples: [ChargeSessionSampleSummary],
699
        minimumYSpan: Double,
700
        value: (ChargeSessionSampleSummary) -> Double
701
    ) -> StoredSeriesSnapshot? {
702
        let sortedSamples = samples.sorted { lhs, rhs in
703
            if lhs.bucketIndex != rhs.bucketIndex {
704
                return lhs.bucketIndex < rhs.bucketIndex
705
            }
706
            return lhs.timestamp < rhs.timestamp
707
        }
708

            
709
        guard
710
            let firstSample = sortedSamples.first,
711
            let lastSample = sortedSamples.last
712
        else {
713
            return nil
714
        }
715

            
716
        let points = sortedSamples.enumerated().map { index, sample in
717
            Measurements.Measurement.Point(
718
                id: index,
719
                timestamp: sample.timestamp,
720
                value: value(sample),
721
                kind: .sample
722
            )
723
        }
724

            
725
        let minimumValue = points.map(\.value).min() ?? 0
726
        let maximumValue = points.map(\.value).max() ?? minimumValue
727
        let context = ChartContext()
728
        context.setBounds(
729
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
730
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
731
            yMin: CGFloat(minimumValue),
732
            yMax: CGFloat(maximumValue)
733
        )
734
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
735

            
736
        return StoredSeriesSnapshot(
737
            points: points,
738
            context: context,
739
            minimumValue: minimumValue,
740
            maximumValue: maximumValue
741
        )
742
    }
743

            
744
    private func storedSeriesChart(
745
        title: String,
746
        unit: String,
747
        strokeColor: Color,
748
        areaChart: Bool = false,
749
        snapshot: StoredSeriesSnapshot
750
    ) -> some View {
751
        VStack(alignment: .leading, spacing: 8) {
752
            HStack(alignment: .firstTextBaseline) {
753
                Text(title)
754
                    .font(.subheadline.weight(.semibold))
755
                Spacer()
756
                Text(
757
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
758
                )
759
                .font(.caption2)
760
                .foregroundColor(.secondary)
761
            }
762

            
763
            Chart(
764
                points: snapshot.points,
765
                context: snapshot.context,
766
                areaChart: areaChart,
767
                strokeColor: strokeColor
768
            )
769
            .frame(height: 118)
770
            .padding(.horizontal, 6)
771
            .padding(.vertical, 8)
772
            .background(
773
                RoundedRectangle(cornerRadius: 16)
774
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
775
            )
776

            
777
            HStack {
778
                Text(snapshot.startLabel)
779
                Spacer()
780
                Text(snapshot.endLabel)
781
            }
782
            .font(.caption2)
783
            .foregroundColor(.secondary)
784
        }
785
    }
786
}
787

            
788
private struct StoredSeriesSnapshot {
789
    let points: [Measurements.Measurement.Point]
790
    let context: ChartContext
791
    let minimumValue: Double
792
    let maximumValue: Double
793

            
794
    var lastValue: Double {
795
        points.last?.value ?? 0
796
    }
797

            
798
    var startLabel: String {
799
        guard let firstTimestamp = points.first?.timestamp else { return "" }
800
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
801
    }
802

            
803
    var endLabel: String {
804
        guard let lastTimestamp = points.last?.timestamp else { return "" }
805
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
806
    }
807
}
808

            
809
private struct ChargedDeviceCheckpointEditorSheetView: View {
810
    @Environment(\.dismiss) private var dismiss
811
    @EnvironmentObject private var appData: AppData
812

            
813
    let sessionID: UUID
814

            
815
    @State private var batteryPercent = ""
816
    @State private var label = ""
817

            
818
    var body: some View {
819
        NavigationView {
820
            Form {
821
                Section(header: Text("Checkpoint")) {
822
                    TextField("Battery %", text: $batteryPercent)
823
                        .keyboardType(.decimalPad)
824
                    TextField("Label (optional)", text: $label)
825
                }
826

            
827
                Section {
828
                    Text("The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.")
829
                        .font(.footnote)
830
                        .foregroundColor(.secondary)
831
                }
832
            }
833
            .navigationTitle("Battery Checkpoint")
834
            .navigationBarTitleDisplayMode(.inline)
835
            .toolbar {
836
                ToolbarItem(placement: .cancellationAction) {
837
                    Button("Cancel") {
838
                        dismiss()
839
                    }
840
                }
841

            
842
                ToolbarItem(placement: .confirmationAction) {
843
                    Button("Save") {
844
                        guard let percent = Double(batteryPercent) else {
845
                            return
846
                        }
847

            
848
                        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
849
                            dismiss()
850
                        }
851
                    }
852
                    .disabled(
853
                        (Double(batteryPercent) ?? -1) < 0
854
                            || (Double(batteryPercent) ?? 101) > 100
855
                    )
856
                }
857
            }
858
        }
859
        .navigationViewStyle(StackNavigationViewStyle())
860
    }
861
}
862

            
863
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
864
    @Environment(\.dismiss) private var dismiss
865
    @EnvironmentObject private var appData: AppData
866

            
867
    let sessionID: UUID
868
    let initialTargetPercent: Double?
869

            
870
    @State private var targetPercent: Double
871

            
872
    init(sessionID: UUID, initialTargetPercent: Double?) {
873
        self.sessionID = sessionID
874
        self.initialTargetPercent = initialTargetPercent
875
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
876
    }
877

            
878
    var body: some View {
879
        NavigationView {
880
            Form {
881
                Section(header: Text("Target Level")) {
882
                    VStack(alignment: .leading, spacing: 12) {
883
                        Text("\(targetPercent.format(decimalDigits: 0))%")
884
                            .font(.title3.weight(.bold))
885
                        Slider(value: $targetPercent, in: 20...100, step: 1)
886
                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
887
                            .font(.footnote)
888
                            .foregroundColor(.secondary)
889
                    }
890
                }
891
            }
892
            .navigationTitle("Battery Target")
893
            .navigationBarTitleDisplayMode(.inline)
894
            .toolbar {
895
                ToolbarItem(placement: .cancellationAction) {
896
                    Button("Cancel") {
897
                        dismiss()
898
                    }
899
                }
900

            
901
                ToolbarItem(placement: .confirmationAction) {
902
                    Button("Save") {
903
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
904
                            dismiss()
905
                        }
906
                    }
907
                }
908
            }
909
        }
910
        .navigationViewStyle(StackNavigationViewStyle())
911
    }
912
}