USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1056 lines | 43.577kb
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?
Bogdan Timofte authored a month ago
17
    @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
Bogdan Timofte authored a month ago
18
    @State private var deleteConfirmationVisibility = false
19

            
20
    let chargedDeviceID: UUID
21

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

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

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

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

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

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

            
135
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
136
        HStack(alignment: .top, spacing: 18) {
137
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
138

            
139
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
140
                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
141
                    .font(.title3.weight(.bold))
142

            
Bogdan Timofte authored a month ago
143
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
144
                    .font(.subheadline.weight(.semibold))
145
                    .foregroundColor(.secondary)
146

            
147
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
148
                    Text("Default meter: \(meterMAC)")
149
                        .font(.caption)
150
                        .foregroundColor(.secondary)
151
                }
152

            
153
                Text(chargedDevice.qrIdentifier)
154
                    .font(.caption2.monospaced())
155
                    .foregroundColor(.secondary)
156
                    .textSelection(.enabled)
157
            }
158

            
159
            Spacer(minLength: 0)
160
        }
161
        .frame(maxWidth: .infinity, alignment: .leading)
162
        .padding(18)
163
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
164
    }
165

            
166
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
167
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
168
            if chargedDevice.isCharger {
169
                chargerInsights(chargedDevice)
170
            } else {
171
                deviceInsights(chargedDevice)
172
            }
173

            
174
            if let notes = chargedDevice.notes, !notes.isEmpty {
175
                Divider()
176
                Text(notes)
177
                    .font(.footnote)
178
                    .foregroundColor(.secondary)
179
                    .frame(maxWidth: .infinity, alignment: .leading)
180
            }
181
        }
182
    }
183

            
184
    @ViewBuilder
185
    private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
186
        MeterInfoRowView(
187
            label: "Charge Modes",
188
            value: chargedDevice.chargingStateAvailability.title
189
        )
190
        MeterInfoRowView(
191
            label: "Charging Support",
192
            value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
193
        )
194
        if chargedDevice.supportsWirelessCharging {
Bogdan Timofte authored a month ago
195
            MeterInfoRowView(
Bogdan Timofte authored a month ago
196
                label: "Wireless Profile",
197
                value: chargedDevice.wirelessChargingProfile.title
Bogdan Timofte authored a month ago
198
            )
Bogdan Timofte authored a month ago
199
        }
200

            
201
        ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
Bogdan Timofte authored a month ago
202
            MeterInfoRowView(
Bogdan Timofte authored a month ago
203
                label: "\(sessionKind.shortTitle) Stop Current",
204
                value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
Bogdan Timofte authored a month ago
205
            )
Bogdan Timofte authored a month ago
206
        }
207
        MeterInfoRowView(
208
            label: "Estimated Capacity",
209
            value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
210
        )
211
        if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
212
            MeterInfoRowView(
Bogdan Timofte authored a month ago
213
                label: "Wired Capacity",
214
                value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
Bogdan Timofte authored a month ago
215
            )
Bogdan Timofte authored a month ago
216
        }
217
        if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
218
            MeterInfoRowView(
219
                label: "Wireless Capacity",
220
                value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
221
            )
222
        }
223
        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
224
            MeterInfoRowView(
225
                label: "Wireless Efficiency",
226
                value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
227
            )
228
        }
229
        MeterInfoRowView(
230
            label: "End-of-Charge Current",
231
            value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
232
        )
233
        MeterInfoRowView(
234
            label: "Charge Sessions",
235
            value: "\(chargedDevice.sessionCount)"
236
        )
237
    }
Bogdan Timofte authored a month ago
238

            
Bogdan Timofte authored a month ago
239
    @ViewBuilder
240
    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
241
        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
Bogdan Timofte authored a month ago
242
            MeterInfoRowView(
Bogdan Timofte authored a month ago
243
                label: "Observed Voltages",
244
                value: chargedDevice.chargerObservedVoltageSelections
245
                    .map { "\($0.format(decimalDigits: 1)) V" }
246
                    .joined(separator: ", ")
Bogdan Timofte authored a month ago
247
            )
Bogdan Timofte authored a month ago
248
        }
249
        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
Bogdan Timofte authored a month ago
250
            MeterInfoRowView(
Bogdan Timofte authored a month ago
251
                label: "Idle Current",
252
                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
Bogdan Timofte authored a month ago
253
            )
Bogdan Timofte authored a month ago
254
        }
255
        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
Bogdan Timofte authored a month ago
256
            MeterInfoRowView(
Bogdan Timofte authored a month ago
257
                label: "Efficiency",
258
                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
Bogdan Timofte authored a month ago
259
            )
Bogdan Timofte authored a month ago
260
        }
261
        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
262
            MeterInfoRowView(
263
                label: "Max Power",
264
                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
265
            )
266
        }
267
        MeterInfoRowView(
268
            label: "Wireless Sessions",
269
            value: "\(chargedDevice.sessionCount)"
270
        )
Bogdan Timofte authored a month ago
271

            
Bogdan Timofte authored a month ago
272
        if chargedDevice.chargerIdleCurrentAmps == nil {
273
            Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
274
                .font(.caption2)
275
                .foregroundColor(.orange)
Bogdan Timofte authored a month ago
276
        }
277
    }
278

            
279
    private func activeSessionCard(
280
        _ activeSession: ChargeSessionSummary,
281
        chargedDevice: ChargedDeviceSummary
282
    ) -> some View {
Bogdan Timofte authored a month ago
283
        MeterInfoCardView(title: "Open Session", tint: .green) {
Bogdan Timofte authored a month ago
284
            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
Bogdan Timofte authored a month ago
285
            MeterInfoRowView(label: "Status", value: activeSession.status.title)
Bogdan Timofte authored a month ago
286
            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
Bogdan Timofte authored a month ago
287
            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
Bogdan Timofte authored a month ago
288
            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
289
            if activeSession.chargingTransportMode == .wireless,
290
               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
291
               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
292
                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
293
            }
Bogdan Timofte authored a month ago
294
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
Bogdan Timofte authored a month ago
295
            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
296
            if chargedDevice.isCharger == false,
297
               let chargerID = activeSession.chargerID,
298
               let charger = appData.chargedDeviceSummary(id: chargerID) {
299
                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
300
            }
301
            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
302
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
303
            }
304
            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
305
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
306
            }
307
            if activeSession.chargingTransportMode == .wired,
308
               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
309
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
310
            }
311
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
312
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
313
            }
314
            if let targetBatteryPercent = activeSession.targetBatteryPercent {
315
                MeterInfoRowView(
316
                    label: "Target Notification",
317
                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
318
                )
319
            }
Bogdan Timofte authored a month ago
320
            if let sessionWarning = sessionWarning(for: activeSession) {
321
                Text(sessionWarning)
322
                    .font(.caption2)
323
                    .foregroundColor(.orange)
324
            }
Bogdan Timofte authored a month ago
325
            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
326
                Text(wirelessSessionHint)
327
                    .font(.caption2)
328
                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
329
            }
330
            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
331
                MeterInfoRowView(
332
                    label: "Predicted Battery",
333
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
334
                )
335
                Text(
336
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
337
                )
338
                .font(.caption2)
339
                .foregroundColor(.secondary)
340
            }
341

            
342
            Button("Add Battery Checkpoint") {
343
                checkpointEditorVisibility = true
344
            }
345
            .frame(maxWidth: .infinity)
346
            .padding(.vertical, 10)
347
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
348
            .buttonStyle(.plain)
349

            
350
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
351
                targetNotificationEditorVisibility = true
352
            }
353
            .frame(maxWidth: .infinity)
354
            .padding(.vertical, 10)
355
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
356
            .buttonStyle(.plain)
357

            
358
            if activeSession.targetBatteryPercent != nil {
359
                Button("Clear Target Notification") {
360
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
361
                }
362
                .frame(maxWidth: .infinity)
363
                .padding(.vertical, 10)
364
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
365
                .buttonStyle(.plain)
366
            }
367

            
Bogdan Timofte authored a month ago
368
            if activeSession.status == .active {
369
                Button("Pause Session") {
370
                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
371
                }
372
                .frame(maxWidth: .infinity)
373
                .padding(.vertical, 10)
374
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
375
                .buttonStyle(.plain)
376
            } else if activeSession.status == .paused {
377
                Button("Resume Session") {
378
                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
379
                }
380
                .frame(maxWidth: .infinity)
381
                .padding(.vertical, 10)
382
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
383
                .buttonStyle(.plain)
384

            
385
                Text("Paused sessions close automatically after 10 minutes.")
386
                    .font(.caption2)
387
                    .foregroundColor(.secondary)
388
            }
389

            
390
            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
391
                pendingSessionStopRequest = DeviceSessionStopRequest(
392
                    sessionID: activeSession.id,
393
                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
394
                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
395
                    explanation: "Add the final battery checkpoint before closing this session."
396
                )
Bogdan Timofte authored a month ago
397
            }
398
            .frame(maxWidth: .infinity)
399
            .padding(.vertical, 10)
Bogdan Timofte authored a month ago
400
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
Bogdan Timofte authored a month ago
401
            .buttonStyle(.plain)
402

            
403
            if activeSession.requiresCompletionConfirmation {
404
                Divider()
405
                if let contradictionPercent = activeSession.completionContradictionPercent {
406
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
407
                        .font(.caption2)
408
                        .foregroundColor(.secondary)
409
                }
410

            
411
                Button("Keep Monitoring") {
412
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
413
                }
414
                .frame(maxWidth: .infinity)
415
                .padding(.vertical, 10)
416
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
417
                .buttonStyle(.plain)
418
            }
419
        }
420
    }
421

            
422
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
423
        VStack(alignment: .leading, spacing: 12) {
424
            Text("Capacity Evolution")
425
                .font(.headline)
426

            
427
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
428
                HStack {
429
                    Text(point.timestamp.format())
430
                        .font(.caption)
431
                        .foregroundColor(.secondary)
432
                    Spacer()
433
                    Text(point.chargingTransportMode.title)
434
                        .font(.caption2)
435
                        .foregroundColor(.secondary)
436
                    Text("•")
437
                        .foregroundColor(.secondary)
438
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
439
                        .font(.footnote.weight(.semibold))
440
                }
441
            }
442
        }
443
        .frame(maxWidth: .infinity, alignment: .leading)
444
        .padding(18)
445
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
446
    }
447

            
448
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
449
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
450
            return activeSession
451
        }
452

            
453
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
454
    }
455

            
456
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
457
        let currentSeries = storedSeriesSnapshot(
458
            from: session.aggregatedSamples,
459
            minimumYSpan: 0.15
460
        ) { $0.averageCurrentAmps }
461
        let energySeries = storedSeriesSnapshot(
462
            from: session.aggregatedSamples,
463
            minimumYSpan: 0.2
464
        ) { $0.measuredEnergyWh }
465

            
466
        return VStack(alignment: .leading, spacing: 14) {
467
            HStack(alignment: .firstTextBaseline) {
468
                VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
469
                    HStack(spacing: 8) {
470
                        Text("Stored Session Curve")
471
                            .font(.headline)
472
                        ContextInfoButton(
473
                            title: "Stored Session Curve",
474
                            message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress."
475
                        )
476
                    }
Bogdan Timofte authored a month ago
477
                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
Bogdan Timofte authored a month ago
478
                        .font(.caption)
479
                        .foregroundColor(.secondary)
480
                }
481

            
482
                Spacer()
483

            
484
                Text("\(session.aggregatedSamples.count) points")
485
                    .font(.caption.weight(.semibold))
486
                    .foregroundColor(.secondary)
487
            }
488

            
489
            if let currentSeries {
490
                storedSeriesChart(
491
                    title: "Current",
492
                    unit: "A",
493
                    strokeColor: .blue,
494
                    snapshot: currentSeries
495
                )
496
            }
497

            
498
            if let energySeries {
499
                storedSeriesChart(
500
                    title: "Energy",
501
                    unit: "Wh",
502
                    strokeColor: .teal,
503
                    areaChart: true,
504
                    snapshot: energySeries
505
                )
506
            }
507

            
508
        }
509
        .frame(maxWidth: .infinity, alignment: .leading)
510
        .padding(18)
511
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
512
    }
513

            
514
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
515
        VStack(alignment: .leading, spacing: 12) {
516
            Text("Typical Charge Curve")
517
                .font(.headline)
518

            
519
            ForEach(chargedDevice.typicalCurve) { point in
520
                HStack {
521
                    Text("\(point.percentBin)%")
522
                        .font(.footnote.weight(.semibold))
523
                    Spacer()
524
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
525
                        .font(.caption.weight(.semibold))
526
                    Text("•")
527
                        .foregroundColor(.secondary)
528
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
529
                        .font(.caption2)
530
                        .foregroundColor(.secondary)
531
                }
532
            }
533
        }
534
        .frame(maxWidth: .infinity, alignment: .leading)
535
        .padding(18)
536
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
537
    }
538

            
539
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
540
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
541
            HStack(spacing: 8) {
542
                Text("Charge Sessions")
543
                    .font(.headline)
544
                ContextInfoButton(
545
                    title: "Charge Sessions",
546
                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
547
                )
548
            }
Bogdan Timofte authored a month ago
549

            
550
            ForEach(chargedDevice.sessions, id: \.id) { session in
551
                VStack(alignment: .leading, spacing: 6) {
552
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
553
                        Text(session.startedAt.format())
554
                            .font(.caption.weight(.semibold))
555
                        Text(session.status.title)
556
                            .font(.caption2.weight(.semibold))
557
                            .padding(.horizontal, 8)
558
                            .padding(.vertical, 4)
559
                            .background(
560
                                Capsule()
561
                                    .fill(statusTint(for: session).opacity(0.16))
562
                            )
563
                        Spacer()
564
                        Button {
565
                            pendingSessionDeletion = session
566
                        } label: {
567
                            Image(systemName: "trash")
568
                                .font(.caption.weight(.semibold))
569
                                .foregroundColor(.red)
570
                                .padding(8)
571
                                .background(
572
                                    Circle()
573
                                        .fill(Color.red.opacity(0.10))
574
                                )
575
                        }
576
                        .buttonStyle(.plain)
577
                    }
578

            
579
                    Text(sessionSummaryLine(session))
580
                        .font(.caption2)
581
                        .foregroundColor(.secondary)
582

            
583
                    MeterInfoRowView(
584
                        label: "Duration",
585
                        value: sessionDurationText(session)
586
                    )
587
                    MeterInfoRowView(
588
                        label: "Energy",
589
                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
590
                    )
591
                    if session.chargingTransportMode == .wireless,
592
                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
593
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
594
                        MeterInfoRowView(
595
                            label: "Charger Energy",
596
                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
597
                        )
598
                    }
599
                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
600
                        MeterInfoRowView(
601
                            label: "Max Current",
602
                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
603
                        )
604
                    }
605
                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
606
                        MeterInfoRowView(
607
                            label: "Max Power",
608
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
609
                        )
610
                    }
611
                    if session.chargingTransportMode == .wired,
612
                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
613
                        MeterInfoRowView(
614
                            label: "Max Voltage",
615
                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
616
                        )
617
                    }
618
                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
619
                        MeterInfoRowView(
620
                            label: "Selected Voltage",
621
                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
622
                        )
623
                    }
624
                    if chargedDevice.isCharger == false,
625
                       let chargerID = session.chargerID,
626
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
627
                        MeterInfoRowView(
628
                            label: "Wireless Charger",
629
                            value: charger.name
630
                        )
631
                    }
632
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
633
                        Text(wirelessSessionHint)
634
                            .font(.caption2)
635
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
636
                    }
637
                }
638
                .padding(14)
639
                .meterCard(
640
                    tint: statusTint(for: session),
641
                    fillOpacity: 0.10,
642
                    strokeOpacity: 0.16,
643
                    cornerRadius: 16
644
                )
645
            }
646
        }
647
        .frame(maxWidth: .infinity, alignment: .leading)
648
        .padding(18)
649
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
650
    }
651

            
652
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
653
        var components: [String] = []
654

            
655
        if let batteryDeltaPercent = session.batteryDeltaPercent {
656
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
657
        }
658

            
659
        if let capacityEstimateWh = session.capacityEstimateWh {
660
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
661
        }
662

            
663
        components.append(session.chargingTransportMode.title)
Bogdan Timofte authored a month ago
664
        components.append(session.chargingStateMode.title)
Bogdan Timofte authored a month ago
665
        components.append(session.sourceMode.title)
666
        return components.joined(separator: " • ")
667
    }
668

            
669
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
670
        guard session.chargingTransportMode == .wireless else {
671
            return nil
672
        }
673

            
674
        var components: [String] = []
675
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
676
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
677
        }
678
        if session.usesEstimatedWirelessEfficiency {
679
            components.append("Estimated from wired baseline and checkpoints")
680
        }
681
        if session.shouldWarnAboutLowWirelessEfficiency {
682
            components.append("Low wireless efficiency, so capacity confidence is reduced")
683
        }
684

            
685
        return components.isEmpty ? nil : components.joined(separator: " • ")
686
    }
687

            
688
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
689
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
690
        let effectiveDuration = max(session.effectiveDuration, 0)
691
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
692
        formatter.unitsStyle = .abbreviated
693
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
694
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
695
    }
696

            
697
    private func statusTint(for session: ChargeSessionSummary) -> Color {
698
        switch session.status {
699
        case .active:
700
            return .green
Bogdan Timofte authored a month ago
701
        case .paused:
702
            return .orange
Bogdan Timofte authored a month ago
703
        case .completed:
704
            return .teal
705
        case .abandoned:
Bogdan Timofte authored a month ago
706
            return .secondary
Bogdan Timofte authored a month ago
707
        }
708
    }
709

            
710
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
711
        switch chargedDevice.deviceClass {
712
        case .iphone:
713
            return .blue
714
        case .watch:
715
            return .green
716
        case .powerbank:
717
            return .orange
718
        case .charger:
719
            return .pink
720
        case .other:
721
            return .secondary
722
        }
723
    }
724

            
725
    private func completionCurrentDescription(
726
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
727
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
728
    ) -> String {
Bogdan Timofte authored a month ago
729
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
730
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
731
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
732
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
733
            }
734
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
735
        }
736

            
Bogdan Timofte authored a month ago
737
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
738
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
739
        }
740

            
741
        return "Learning"
742
    }
743

            
Bogdan Timofte authored a month ago
744
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
745
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
746
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
747
                ChargeSessionKind(
748
                    chargingTransportMode: chargingTransportMode,
749
                    chargingStateMode: chargingStateMode
750
                )
751
            }
752
        }
753
    }
754

            
755
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
756
        if session.autoStopEnabled == false {
757
            return "Manual"
758
        }
759

            
760
        if let sessionWarning = sessionWarning(for: session),
761
           sessionWarning.contains("idle-current") {
762
            return "Blocked by charger setup"
763
        }
764

            
765
        if session.stopThresholdAmps > 0 {
766
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
767
        }
768

            
769
        return "Learning"
770
    }
771

            
772
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
773
        guard session.chargingTransportMode == .wireless,
774
              let chargerID = session.chargerID,
775
              let charger = appData.chargedDeviceSummary(id: chargerID),
776
              charger.chargerIdleCurrentAmps == nil else {
777
            return nil
778
        }
779

            
780
        return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session."
781
    }
782

            
Bogdan Timofte authored a month ago
783
    private var deletionTitle: String {
784
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
785
    }
786

            
787
    private var deletionMessage: String {
788
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
789
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
790
        }
791
        return "This removes the device and its stored charging history from the library."
792
    }
793

            
794
    private func storedSeriesSnapshot(
795
        from samples: [ChargeSessionSampleSummary],
796
        minimumYSpan: Double,
797
        value: (ChargeSessionSampleSummary) -> Double
798
    ) -> StoredSeriesSnapshot? {
799
        let sortedSamples = samples.sorted { lhs, rhs in
800
            if lhs.bucketIndex != rhs.bucketIndex {
801
                return lhs.bucketIndex < rhs.bucketIndex
802
            }
803
            return lhs.timestamp < rhs.timestamp
804
        }
805

            
806
        guard
807
            let firstSample = sortedSamples.first,
808
            let lastSample = sortedSamples.last
809
        else {
810
            return nil
811
        }
812

            
813
        let points = sortedSamples.enumerated().map { index, sample in
814
            Measurements.Measurement.Point(
815
                id: index,
816
                timestamp: sample.timestamp,
817
                value: value(sample),
818
                kind: .sample
819
            )
820
        }
821

            
822
        let minimumValue = points.map(\.value).min() ?? 0
823
        let maximumValue = points.map(\.value).max() ?? minimumValue
824
        let context = ChartContext()
825
        context.setBounds(
826
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
827
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
828
            yMin: CGFloat(minimumValue),
829
            yMax: CGFloat(maximumValue)
830
        )
831
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
832

            
833
        return StoredSeriesSnapshot(
834
            points: points,
835
            context: context,
836
            minimumValue: minimumValue,
837
            maximumValue: maximumValue
838
        )
839
    }
840

            
841
    private func storedSeriesChart(
842
        title: String,
843
        unit: String,
844
        strokeColor: Color,
845
        areaChart: Bool = false,
846
        snapshot: StoredSeriesSnapshot
847
    ) -> some View {
848
        VStack(alignment: .leading, spacing: 8) {
849
            HStack(alignment: .firstTextBaseline) {
850
                Text(title)
851
                    .font(.subheadline.weight(.semibold))
852
                Spacer()
853
                Text(
854
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
855
                )
856
                .font(.caption2)
857
                .foregroundColor(.secondary)
858
            }
859

            
860
            Chart(
861
                points: snapshot.points,
862
                context: snapshot.context,
863
                areaChart: areaChart,
864
                strokeColor: strokeColor
865
            )
866
            .frame(height: 118)
867
            .padding(.horizontal, 6)
868
            .padding(.vertical, 8)
869
            .background(
870
                RoundedRectangle(cornerRadius: 16)
871
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
872
            )
873

            
874
            HStack {
875
                Text(snapshot.startLabel)
876
                Spacer()
877
                Text(snapshot.endLabel)
878
            }
879
            .font(.caption2)
880
            .foregroundColor(.secondary)
881
        }
882
    }
883
}
884

            
885
private struct StoredSeriesSnapshot {
886
    let points: [Measurements.Measurement.Point]
887
    let context: ChartContext
888
    let minimumValue: Double
889
    let maximumValue: Double
890

            
891
    var lastValue: Double {
892
        points.last?.value ?? 0
893
    }
894

            
895
    var startLabel: String {
896
        guard let firstTimestamp = points.first?.timestamp else { return "" }
897
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
898
    }
899

            
900
    var endLabel: String {
901
        guard let lastTimestamp = points.last?.timestamp else { return "" }
902
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
903
    }
904
}
905

            
906
private struct ChargedDeviceCheckpointEditorSheetView: View {
907
    @Environment(\.dismiss) private var dismiss
908
    @EnvironmentObject private var appData: AppData
909

            
910
    let sessionID: UUID
911

            
912
    @State private var batteryPercent = ""
913
    @State private var label = ""
Bogdan Timofte authored a month ago
914
    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
915

            
916
    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
917
        guard let percent = Double(batteryPercent) else {
918
            return nil
919
        }
920
        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID)
921
    }
Bogdan Timofte authored a month ago
922

            
923
    var body: some View {
924
        NavigationView {
925
            Form {
Bogdan Timofte authored a month ago
926
                Section(
927
                    header: ContextInfoHeader(
928
                        title: "Checkpoint",
929
                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
930
                    )
931
                ) {
Bogdan Timofte authored a month ago
932
                    TextField("Battery %", text: $batteryPercent)
933
                        .keyboardType(.decimalPad)
934
                    TextField("Label (optional)", text: $label)
935
                }
936

            
Bogdan Timofte authored a month ago
937
                if let plausibilityWarning {
938
                    Section(header: Text(plausibilityWarning.title)) {
939
                        Text(plausibilityWarning.message)
940
                            .font(.footnote)
941
                            .foregroundColor(.orange)
942
                    }
Bogdan Timofte authored a month ago
943
                }
944
            }
945
            .navigationTitle("Battery Checkpoint")
946
            .navigationBarTitleDisplayMode(.inline)
947
            .toolbar {
948
                ToolbarItem(placement: .cancellationAction) {
949
                    Button("Cancel") {
950
                        dismiss()
951
                    }
952
                }
953

            
954
                ToolbarItem(placement: .confirmationAction) {
955
                    Button("Save") {
Bogdan Timofte authored a month ago
956
                        saveCheckpoint()
Bogdan Timofte authored a month ago
957
                    }
958
                    .disabled(
959
                        (Double(batteryPercent) ?? -1) < 0
960
                            || (Double(batteryPercent) ?? 101) > 100
961
                    )
962
                }
963
            }
964
        }
965
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
966
        .alert(item: $confirmationWarning) { warning in
967
            Alert(
968
                title: Text(warning.title),
969
                message: Text(warning.message),
970
                primaryButton: .destructive(Text("Save Anyway")) {
971
                    saveCheckpoint(forceOverride: true)
972
                },
973
                secondaryButton: .cancel()
974
            )
975
        }
976
    }
977

            
978
    private func saveCheckpoint(forceOverride: Bool = false) {
979
        guard let percent = Double(batteryPercent) else {
980
            return
981
        }
982

            
983
        if !forceOverride, let plausibilityWarning {
984
            confirmationWarning = plausibilityWarning
985
            return
986
        }
987

            
988
        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
989
            dismiss()
990
        }
Bogdan Timofte authored a month ago
991
    }
992
}
993

            
994
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
995
    @Environment(\.dismiss) private var dismiss
996
    @EnvironmentObject private var appData: AppData
997

            
998
    let sessionID: UUID
999
    let initialTargetPercent: Double?
1000

            
1001
    @State private var targetPercent: Double
1002

            
1003
    init(sessionID: UUID, initialTargetPercent: Double?) {
1004
        self.sessionID = sessionID
1005
        self.initialTargetPercent = initialTargetPercent
1006
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1007
    }
1008

            
1009
    var body: some View {
1010
        NavigationView {
1011
            Form {
Bogdan Timofte authored a month ago
1012
                Section(
1013
                    header: ContextInfoHeader(
1014
                        title: "Target Level",
1015
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
1016
                    )
1017
                ) {
Bogdan Timofte authored a month ago
1018
                    VStack(alignment: .leading, spacing: 12) {
1019
                        Text("\(targetPercent.format(decimalDigits: 0))%")
1020
                            .font(.title3.weight(.bold))
1021
                        Slider(value: $targetPercent, in: 20...100, step: 1)
1022
                    }
1023
                }
1024
            }
1025
            .navigationTitle("Battery Target")
1026
            .navigationBarTitleDisplayMode(.inline)
1027
            .toolbar {
1028
                ToolbarItem(placement: .cancellationAction) {
1029
                    Button("Cancel") {
1030
                        dismiss()
1031
                    }
1032
                }
1033

            
1034
                ToolbarItem(placement: .confirmationAction) {
1035
                    Button("Save") {
1036
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
1037
                            dismiss()
1038
                        }
1039
                    }
1040
                }
1041
            }
1042
        }
1043
        .navigationViewStyle(StackNavigationViewStyle())
1044
    }
1045
}
Bogdan Timofte authored a month ago
1046

            
1047
private struct DeviceSessionStopRequest: Identifiable {
1048
    let sessionID: UUID
1049
    let title: String
1050
    let confirmTitle: String
1051
    let explanation: String
1052

            
1053
    var id: UUID {
1054
        sessionID
1055
    }
1056
}