USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1055 lines | 43.517kb
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()
690
        formatter.allowedUnits = session.duration >= 3600 ? [.hour, .minute] : [.minute, .second]
691
        formatter.unitsStyle = .abbreviated
692
        formatter.zeroFormattingBehavior = .dropAll
693
        return formatter.string(from: max(session.duration, 0)) ?? "0m"
694
    }
695

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

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

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

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

            
740
        return "Learning"
741
    }
742

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

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

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

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

            
768
        return "Learning"
769
    }
770

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
909
    let sessionID: UUID
910

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

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

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

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

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

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

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

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

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

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

            
1000
    @State private var targetPercent: Double
1001

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

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

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

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

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