USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
989 lines | 41.219kb
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 targetNotificationEditorVisibility = false
Bogdan Timofte authored a month ago
15
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
Bogdan Timofte authored a month ago
16
    @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
Bogdan Timofte authored a month ago
17
    @State private var deleteConfirmationVisibility = false
18

            
19
    let chargedDeviceID: UUID
20

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

            
Bogdan Timofte authored a month ago
29
                        if chargedDevice.isCharger {
30
                            standbyPowerCard(chargedDevice)
31
                        }
32

            
Bogdan Timofte authored a month ago
33
                        if let activeSession = chargedDevice.activeSession {
34
                            activeSessionCard(activeSession, chargedDevice: chargedDevice)
35
                        }
36

            
37
                        if let curveSession = preferredStoredCurveSession(for: chargedDevice) {
38
                            storedCurveCard(curveSession)
39
                        }
40

            
41
                        if !chargedDevice.capacityHistory.isEmpty {
42
                            capacityEvolutionCard(chargedDevice)
43
                        }
44

            
45
                        if !chargedDevice.typicalCurve.isEmpty {
46
                            typicalCurveCard(chargedDevice)
47
                        }
48

            
Bogdan Timofte authored a month ago
49
                        if !closedSessions(for: chargedDevice).isEmpty {
50
                            sessionHistorySummaryCard(chargedDevice)
Bogdan Timofte authored a month ago
51
                        }
52
                    }
53
                    .padding()
54
                }
55
                .background(
56
                    LinearGradient(
57
                        colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
58
                        startPoint: .topLeading,
59
                        endPoint: .bottomTrailing
60
                    )
61
                    .ignoresSafeArea()
62
                )
63
                .navigationTitle(chargedDevice.name)
64
                .toolbar {
65
                    ToolbarItemGroup(placement: .primaryAction) {
66
                        Button("Edit") {
67
                            editorVisibility = true
68
                        }
69
                        Button(role: .destructive) {
70
                            deleteConfirmationVisibility = true
71
                        } label: {
72
                            Image(systemName: "trash")
73
                        }
74
                    }
75
                }
76
            } else {
77
                Text("This device is no longer available.")
78
                    .foregroundColor(.secondary)
79
                    .navigationTitle("Device")
80
            }
81
        }
82
        .sheet(isPresented: $editorVisibility) {
83
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
84
                if chargedDevice.isCharger {
85
                    ChargerEditorSheetView(
86
                        appData: appData,
87
                        chargedDevice: chargedDevice
88
                    )
89
                } else {
90
                    ChargedDeviceEditorSheetView(
91
                        meterMACAddress: nil,
92
                        chargedDevice: chargedDevice
93
                    )
94
                    .environmentObject(appData)
95
                }
Bogdan Timofte authored a month ago
96
            }
97
        }
98
        .sheet(isPresented: $targetNotificationEditorVisibility) {
99
            if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
100
                ChargedDeviceTargetNotificationEditorSheetView(
101
                    sessionID: activeSession.id,
102
                    initialTargetPercent: activeSession.targetBatteryPercent
103
                )
104
                .environmentObject(appData)
105
            }
106
        }
Bogdan Timofte authored a month ago
107
        .sheet(item: $pendingSessionStopRequest) { request in
108
            ChargeSessionCompletionSheetView(
109
                sessionID: request.sessionID,
110
                title: request.title,
111
                confirmTitle: request.confirmTitle,
112
                explanation: request.explanation
113
            )
114
            .environmentObject(appData)
115
        }
Bogdan Timofte authored a month ago
116
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
117
            Alert(
118
                title: Text("Delete Battery Checkpoint"),
119
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
120
                primaryButton: .destructive(Text("Delete")) {
121
                    _ = appData.deleteBatteryCheckpoint(
122
                        checkpointID: checkpoint.id,
123
                        for: checkpoint.sessionID
124
                    )
125
                },
126
                secondaryButton: .cancel()
127
            )
128
        }
Bogdan Timofte authored a month ago
129
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
130
            Button("Delete", role: .destructive) {
131
                if appData.deleteChargedDevice(id: chargedDeviceID) {
132
                    dismiss()
133
                }
134
            }
135
            Button("Cancel", role: .cancel) {}
136
        } message: {
137
            Text(deletionMessage)
138
        }
139
    }
140

            
141
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
142
        HStack(alignment: .top, spacing: 18) {
143
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
144

            
145
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
146
                ChargedDeviceIdentityLabelView(
147
                    chargedDevice: chargedDevice,
148
                    iconPointSize: 22
149
                )
150
                .font(.title3.weight(.bold))
Bogdan Timofte authored a month ago
151

            
Bogdan Timofte authored a month ago
152
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
153
                    .font(.subheadline.weight(.semibold))
154
                    .foregroundColor(.secondary)
155

            
156
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
157
                    Text("Default meter: \(meterMAC)")
158
                        .font(.caption)
159
                        .foregroundColor(.secondary)
160
                }
161

            
162
                Text(chargedDevice.qrIdentifier)
163
                    .font(.caption2.monospaced())
164
                    .foregroundColor(.secondary)
165
                    .textSelection(.enabled)
166
            }
167

            
168
            Spacer(minLength: 0)
169
        }
170
        .frame(maxWidth: .infinity, alignment: .leading)
171
        .padding(18)
172
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
173
    }
174

            
175
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
176
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
177
            if chargedDevice.isCharger {
178
                chargerInsights(chargedDevice)
179
            } else {
180
                deviceInsights(chargedDevice)
181
            }
182

            
183
            if let notes = chargedDevice.notes, !notes.isEmpty {
184
                Divider()
185
                Text(notes)
186
                    .font(.footnote)
187
                    .foregroundColor(.secondary)
188
                    .frame(maxWidth: .infinity, alignment: .leading)
189
            }
190
        }
191
    }
192

            
193
    @ViewBuilder
194
    private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
195
        if chargedDevice.hasMultipleChargingStateModes {
196
            MeterInfoRowView(
197
                label: "Charge Modes",
198
                value: chargedDevice.chargingStateAvailability.title
199
            )
200
        }
201
        if chargedDevice.hasMultipleChargingTransports {
202
            MeterInfoRowView(
203
                label: "Charging Support",
204
                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
205
            )
206
        }
207
        if chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
208
            MeterInfoRowView(
Bogdan Timofte authored a month ago
209
                label: "Wireless Profile",
210
                value: chargedDevice.wirelessChargingProfile.title
Bogdan Timofte authored a month ago
211
            )
Bogdan Timofte authored a month ago
212
        }
213

            
214
        ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
Bogdan Timofte authored a month ago
215
            MeterInfoRowView(
Bogdan Timofte authored a month ago
216
                label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind),
Bogdan Timofte authored a month ago
217
                value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
Bogdan Timofte authored a month ago
218
            )
Bogdan Timofte authored a month ago
219
        }
220
        MeterInfoRowView(
221
            label: "Estimated Capacity",
222
            value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
223
        )
224
        if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
225
            if chargedDevice.hasMultipleChargingTransports {
226
                MeterInfoRowView(
227
                    label: "Wired Capacity",
228
                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
229
                )
230
            }
Bogdan Timofte authored a month ago
231
        }
232
        if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
233
            if chargedDevice.hasMultipleChargingTransports {
234
                MeterInfoRowView(
235
                    label: "Wireless Capacity",
236
                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
237
                )
238
            }
Bogdan Timofte authored a month ago
239
        }
Bogdan Timofte authored a month ago
240
        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor,
241
           chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
242
            MeterInfoRowView(
243
                label: "Wireless Efficiency",
244
                value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
245
            )
246
        }
247
        MeterInfoRowView(
248
            label: "Charge Sessions",
249
            value: "\(chargedDevice.sessionCount)"
250
        )
251
    }
Bogdan Timofte authored a month ago
252

            
Bogdan Timofte authored a month ago
253
    @ViewBuilder
254
    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
255
        if let chargerType = chargedDevice.chargerType {
256
            MeterInfoRowView(
257
                label: "Type",
258
                value: chargerType.title
259
            )
260
        }
Bogdan Timofte authored a month ago
261
        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
Bogdan Timofte authored a month ago
262
            MeterInfoRowView(
Bogdan Timofte authored a month ago
263
                label: "Observed Voltages",
264
                value: chargedDevice.chargerObservedVoltageSelections
265
                    .map { "\($0.format(decimalDigits: 1)) V" }
266
                    .joined(separator: ", ")
Bogdan Timofte authored a month ago
267
            )
Bogdan Timofte authored a month ago
268
        }
269
        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
Bogdan Timofte authored a month ago
270
            MeterInfoRowView(
Bogdan Timofte authored a month ago
271
                label: "Idle Current",
272
                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
Bogdan Timofte authored a month ago
273
            )
Bogdan Timofte authored a month ago
274
        }
275
        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
Bogdan Timofte authored a month ago
276
            MeterInfoRowView(
Bogdan Timofte authored a month ago
277
                label: "Efficiency",
278
                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
Bogdan Timofte authored a month ago
279
            )
Bogdan Timofte authored a month ago
280
        }
281
        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
282
            MeterInfoRowView(
283
                label: "Max Power",
284
                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
285
            )
286
        }
Bogdan Timofte authored a month ago
287
        if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
288
            MeterInfoRowView(
289
                label: "Standby Power",
290
                value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W"
291
            )
292
            MeterInfoRowView(
293
                label: "Standby Projection",
294
                value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year"
295
            )
296
        }
Bogdan Timofte authored a month ago
297
        MeterInfoRowView(
298
            label: "Wireless Sessions",
299
            value: "\(chargedDevice.sessionCount)"
300
        )
Bogdan Timofte authored a month ago
301

            
Bogdan Timofte authored a month ago
302
        if chargedDevice.chargerIdleCurrentAmps == nil {
303
            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.")
304
                .font(.caption2)
305
                .foregroundColor(.orange)
Bogdan Timofte authored a month ago
306
        }
307
    }
308

            
Bogdan Timofte authored a month ago
309
    private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
310
        let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement
311

            
312
        return MeterInfoCardView(
313
            title: "Standby Power",
314
            tint: .orange
315
        ) {
316
            if standbyMeasurementMeters.isEmpty {
317
                Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
318
                    .font(.footnote)
319
                    .foregroundColor(.secondary)
320
                    .frame(maxWidth: .infinity, alignment: .leading)
321
            } else {
322
                NavigationLink(
323
                    destination: ChargerStandbyPowerWizardView(
324
                        preferredChargerID: chargedDevice.id,
325
                        locksChargerSelection: true
326
                    )
327
                ) {
328
                    Label("New Measurement", systemImage: "plus.circle.fill")
329
                        .font(.subheadline.weight(.semibold))
330
                        .foregroundColor(.orange)
331
                }
332
                .buttonStyle(.plain)
333
            }
334

            
335
            if let latestMeasurement {
336
                Divider()
337

            
338
                NavigationLink(
339
                    destination: ChargerStandbyPowerMeasurementDetailView(
340
                        chargerID: chargedDevice.id,
341
                        measurementID: latestMeasurement.id
342
                    )
343
                ) {
344
                    VStack(alignment: .leading, spacing: 8) {
345
                        HStack {
346
                            Text("Latest Measurement")
347
                                .font(.subheadline.weight(.semibold))
348
                                .foregroundColor(.primary)
349
                            Spacer()
350
                            Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
351
                                .font(.subheadline.weight(.bold))
352
                                .foregroundColor(.primary)
353
                                .monospacedDigit()
354
                        }
355

            
356
                        Text(
357
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
358
                        )
359
                        .font(.caption)
360
                        .foregroundColor(.secondary)
361
                    }
362
                }
363
                .buttonStyle(.plain)
364
            }
365

            
366
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
367
                Divider()
368

            
369
                NavigationLink(
370
                    destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
371
                ) {
372
                    Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
373
                        .font(.subheadline.weight(.semibold))
374
                        .foregroundColor(.blue)
375
                }
376
                .buttonStyle(.plain)
377
            }
378
        }
379
    }
380

            
Bogdan Timofte authored a month ago
381
    private func activeSessionCard(
382
        _ activeSession: ChargeSessionSummary,
383
        chargedDevice: ChargedDeviceSummary
384
    ) -> some View {
Bogdan Timofte authored a month ago
385
        MeterInfoCardView(title: "Open Session", tint: .green) {
Bogdan Timofte authored a month ago
386
            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
Bogdan Timofte authored a month ago
387
            MeterInfoRowView(label: "Status", value: activeSession.status.title)
Bogdan Timofte authored a month ago
388
            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
Bogdan Timofte authored a month ago
389
            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
Bogdan Timofte authored a month ago
390
            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
391
            if activeSession.chargingTransportMode == .wireless,
392
               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
393
               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
394
                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
395
            }
Bogdan Timofte authored a month ago
396
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
Bogdan Timofte authored a month ago
397
            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
398
            if chargedDevice.isCharger == false,
399
               let chargerID = activeSession.chargerID,
400
               let charger = appData.chargedDeviceSummary(id: chargerID) {
401
                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
402
            }
403
            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
404
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
405
            }
406
            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
407
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
408
            }
409
            if activeSession.chargingTransportMode == .wired,
410
               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
411
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
412
            }
413
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
414
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
415
            }
416
            if let targetBatteryPercent = activeSession.targetBatteryPercent {
417
                MeterInfoRowView(
418
                    label: "Target Notification",
419
                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
420
                )
421
            }
Bogdan Timofte authored a month ago
422
            if let sessionWarning = sessionWarning(for: activeSession) {
423
                Text(sessionWarning)
424
                    .font(.caption2)
425
                    .foregroundColor(.orange)
426
            }
Bogdan Timofte authored a month ago
427
            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
428
                Text(wirelessSessionHint)
429
                    .font(.caption2)
430
                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
431
            }
432
            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
433
                MeterInfoRowView(
434
                    label: "Predicted Battery",
435
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
436
                )
437
                Text(
438
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
439
                )
440
                .font(.caption2)
441
                .foregroundColor(.secondary)
442
            }
443

            
Bogdan Timofte authored a month ago
444
            BatteryCheckpointSectionView(
445
                sessionID: activeSession.id,
446
                checkpoints: activeSession.checkpoints,
447
                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
448
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
449
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
450
                effectiveEnergyWhOverride: nil,
451
                measuredChargeAhOverride: nil,
452
                onDelete: { checkpoint in
453
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
454
                }
Bogdan Timofte authored a month ago
455
            )
Bogdan Timofte authored a month ago
456

            
Bogdan Timofte authored a month ago
457
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
458
                targetNotificationEditorVisibility = true
459
            }
460
            .frame(maxWidth: .infinity)
461
            .padding(.vertical, 10)
462
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
463
            .buttonStyle(.plain)
464

            
465
            if activeSession.targetBatteryPercent != nil {
466
                Button("Clear Target Notification") {
467
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
468
                }
469
                .frame(maxWidth: .infinity)
470
                .padding(.vertical, 10)
471
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
472
                .buttonStyle(.plain)
473
            }
474

            
Bogdan Timofte authored a month ago
475
            if activeSession.status == .active {
476
                Button("Pause Session") {
477
                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
478
                }
479
                .frame(maxWidth: .infinity)
480
                .padding(.vertical, 10)
481
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
482
                .buttonStyle(.plain)
483
            } else if activeSession.status == .paused {
484
                Button("Resume Session") {
485
                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
486
                }
487
                .frame(maxWidth: .infinity)
488
                .padding(.vertical, 10)
489
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
490
                .buttonStyle(.plain)
491

            
492
                Text("Paused sessions close automatically after 10 minutes.")
493
                    .font(.caption2)
494
                    .foregroundColor(.secondary)
495
            }
496

            
497
            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
498
                pendingSessionStopRequest = DeviceSessionStopRequest(
499
                    sessionID: activeSession.id,
500
                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
501
                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
502
                    explanation: "Add the final battery checkpoint before closing this session."
503
                )
Bogdan Timofte authored a month ago
504
            }
505
            .frame(maxWidth: .infinity)
506
            .padding(.vertical, 10)
Bogdan Timofte authored a month ago
507
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
Bogdan Timofte authored a month ago
508
            .buttonStyle(.plain)
509

            
510
            if activeSession.requiresCompletionConfirmation {
511
                Divider()
512
                if let contradictionPercent = activeSession.completionContradictionPercent {
513
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
514
                        .font(.caption2)
515
                        .foregroundColor(.secondary)
516
                }
517

            
518
                Button("Keep Monitoring") {
519
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
520
                }
521
                .frame(maxWidth: .infinity)
522
                .padding(.vertical, 10)
523
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
524
                .buttonStyle(.plain)
525
            }
526
        }
527
    }
528

            
529
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
530
        VStack(alignment: .leading, spacing: 12) {
531
            Text("Capacity Evolution")
532
                .font(.headline)
533

            
534
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
535
                HStack {
536
                    Text(point.timestamp.format())
537
                        .font(.caption)
538
                        .foregroundColor(.secondary)
539
                    Spacer()
Bogdan Timofte authored a month ago
540
                    if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
541
                        Text(point.chargingTransportMode.title)
542
                            .font(.caption2)
543
                            .foregroundColor(.secondary)
544
                        Text("•")
545
                            .foregroundColor(.secondary)
546
                    }
Bogdan Timofte authored a month ago
547
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
548
                        .font(.footnote.weight(.semibold))
549
                }
550
            }
551
        }
552
        .frame(maxWidth: .infinity, alignment: .leading)
553
        .padding(18)
554
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
555
    }
556

            
557
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
558
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
559
            return activeSession
560
        }
561

            
562
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
563
    }
564

            
565
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
566
        let displayedSamples = session.displayedAggregatedSamples
Bogdan Timofte authored a month ago
567
        let currentSeries = storedSeriesSnapshot(
Bogdan Timofte authored a month ago
568
            from: displayedSamples,
Bogdan Timofte authored a month ago
569
            minimumYSpan: 0.15
570
        ) { $0.averageCurrentAmps }
571
        let energySeries = storedSeriesSnapshot(
Bogdan Timofte authored a month ago
572
            from: displayedSamples,
Bogdan Timofte authored a month ago
573
            minimumYSpan: 0.2
574
        ) { $0.measuredEnergyWh }
575

            
576
        return VStack(alignment: .leading, spacing: 14) {
577
            HStack(alignment: .firstTextBaseline) {
578
                VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
579
                    HStack(spacing: 8) {
580
                        Text("Stored Session Curve")
581
                            .font(.headline)
582
                        ContextInfoButton(
583
                            title: "Stored Session Curve",
584
                            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."
585
                        )
586
                    }
Bogdan Timofte authored a month ago
587
                    Text(session.isTrimmed
588
                         ? "Showing the saved trim window at aggregated resolution."
589
                         : (session.status.isOpen
590
                            ? "Open session, persisted as aggregated samples."
591
                            : "Most recent persisted session at aggregated resolution."))
Bogdan Timofte authored a month ago
592
                        .font(.caption)
593
                        .foregroundColor(.secondary)
594
                }
595

            
596
                Spacer()
597

            
Bogdan Timofte authored a month ago
598
                Text("\(displayedSamples.count) points")
Bogdan Timofte authored a month ago
599
                    .font(.caption.weight(.semibold))
600
                    .foregroundColor(.secondary)
601
            }
602

            
603
            if let currentSeries {
604
                storedSeriesChart(
605
                    title: "Current",
606
                    unit: "A",
607
                    strokeColor: .blue,
608
                    snapshot: currentSeries
609
                )
610
            }
611

            
612
            if let energySeries {
613
                storedSeriesChart(
614
                    title: "Energy",
615
                    unit: "Wh",
616
                    strokeColor: .teal,
617
                    areaChart: true,
618
                    snapshot: energySeries
619
                )
620
            }
621

            
622
        }
623
        .frame(maxWidth: .infinity, alignment: .leading)
624
        .padding(18)
625
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
626
    }
627

            
628
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
629
        VStack(alignment: .leading, spacing: 12) {
630
            Text("Typical Charge Curve")
631
                .font(.headline)
632

            
633
            ForEach(chargedDevice.typicalCurve) { point in
634
                HStack {
635
                    Text("\(point.percentBin)%")
636
                        .font(.footnote.weight(.semibold))
637
                    Spacer()
638
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
639
                        .font(.caption.weight(.semibold))
640
                    Text("•")
641
                        .foregroundColor(.secondary)
642
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
643
                        .font(.caption2)
644
                        .foregroundColor(.secondary)
645
                }
646
            }
647
        }
648
        .frame(maxWidth: .infinity, alignment: .leading)
649
        .padding(18)
650
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
651
    }
652

            
Bogdan Timofte authored a month ago
653
    private func sessionHistorySummaryCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
654
        let sessions = closedSessions(for: chargedDevice)
655
        let latestSession = sessions.first
656
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
Bogdan Timofte authored a month ago
657

            
Bogdan Timofte authored a month ago
658
        return MeterInfoCardView(title: "Session History", tint: .teal) {
659
            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
660
            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
661
            if let latestSession {
662
                MeterInfoRowView(label: "Latest", value: latestSession.startedAt.format())
663
            }
Bogdan Timofte authored a month ago
664

            
Bogdan Timofte authored a month ago
665
            NavigationLink(
666
                destination: ChargedDeviceSessionsView(chargedDeviceID: chargedDevice.id)
667
            ) {
668
                Label("Manage Sessions", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
669
                    .font(.subheadline.weight(.semibold))
670
                    .frame(maxWidth: .infinity)
671
                    .padding(.vertical, 10)
672
                    .meterCard(tint: .teal, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
Bogdan Timofte authored a month ago
673
            }
Bogdan Timofte authored a month ago
674
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
675
        }
676
    }
677

            
Bogdan Timofte authored a month ago
678
    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
679
        chargedDevice.sessions.filter { !$0.status.isOpen }
Bogdan Timofte authored a month ago
680
    }
681

            
682
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
683
        guard session.chargingTransportMode == .wireless else {
684
            return nil
685
        }
686

            
687
        var components: [String] = []
688
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
689
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
690
        }
691
        if session.usesEstimatedWirelessEfficiency {
692
            components.append("Estimated from wired baseline and checkpoints")
693
        }
694
        if session.shouldWarnAboutLowWirelessEfficiency {
695
            components.append("Low wireless efficiency, so capacity confidence is reduced")
696
        }
697

            
698
        return components.isEmpty ? nil : components.joined(separator: " • ")
699
    }
700

            
701
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
702
        switch chargedDevice.deviceClass {
703
        case .iphone:
704
            return .blue
705
        case .watch:
706
            return .green
707
        case .powerbank:
708
            return .orange
709
        case .charger:
710
            return .pink
711
        case .other:
712
            return .secondary
713
        }
714
    }
715

            
Bogdan Timofte authored a month ago
716
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
717
        if wattHours >= 1000 {
718
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
719
        }
720
        return "\(wattHours.format(decimalDigits: 2)) Wh"
721
    }
722

            
723
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
724
        appData.meterSummaries.filter { $0.meter != nil }
725
    }
726

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

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

            
743
        return "Learning"
744
    }
745

            
Bogdan Timofte authored a month ago
746
    private func completionCurrentLabel(
747
        for chargedDevice: ChargedDeviceSummary,
748
        sessionKind: ChargeSessionKind
749
    ) -> String {
750
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
751
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
752

            
753
        switch (showsTransport, showsState) {
754
        case (true, true):
755
            return "\(sessionKind.shortTitle) Stop Current"
756
        case (true, false):
757
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
758
        case (false, true):
759
            return "\(sessionKind.chargingStateMode.title) Stop Current"
760
        case (false, false):
761
            return "Stop Current"
762
        }
763
    }
764

            
Bogdan Timofte authored a month ago
765
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
766
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
767
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
768
                ChargeSessionKind(
769
                    chargingTransportMode: chargingTransportMode,
770
                    chargingStateMode: chargingStateMode
771
                )
772
            }
773
        }
774
    }
775

            
776
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
777
        if session.autoStopEnabled == false {
778
            return "Manual"
779
        }
780

            
781
        if let sessionWarning = sessionWarning(for: session),
782
           sessionWarning.contains("idle-current") {
783
            return "Blocked by charger setup"
784
        }
785

            
786
        if session.stopThresholdAmps > 0 {
787
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
788
        }
789

            
790
        return "Learning"
791
    }
792

            
793
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
794
        guard session.chargingTransportMode == .wireless,
795
              let chargerID = session.chargerID,
796
              let charger = appData.chargedDeviceSummary(id: chargerID),
797
              charger.chargerIdleCurrentAmps == nil else {
798
            return nil
799
        }
800

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

            
Bogdan Timofte authored a month ago
804
    private var deletionTitle: String {
805
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
806
    }
807

            
808
    private var deletionMessage: String {
809
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
810
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
811
        }
812
        return "This removes the device and its stored charging history from the library."
813
    }
814

            
815
    private func storedSeriesSnapshot(
816
        from samples: [ChargeSessionSampleSummary],
817
        minimumYSpan: Double,
818
        value: (ChargeSessionSampleSummary) -> Double
819
    ) -> StoredSeriesSnapshot? {
820
        let sortedSamples = samples.sorted { lhs, rhs in
821
            if lhs.bucketIndex != rhs.bucketIndex {
822
                return lhs.bucketIndex < rhs.bucketIndex
823
            }
824
            return lhs.timestamp < rhs.timestamp
825
        }
826

            
827
        guard
828
            let firstSample = sortedSamples.first,
829
            let lastSample = sortedSamples.last
830
        else {
831
            return nil
832
        }
833

            
834
        let points = sortedSamples.enumerated().map { index, sample in
835
            Measurements.Measurement.Point(
836
                id: index,
837
                timestamp: sample.timestamp,
838
                value: value(sample),
839
                kind: .sample
840
            )
841
        }
842

            
843
        let minimumValue = points.map(\.value).min() ?? 0
844
        let maximumValue = points.map(\.value).max() ?? minimumValue
845
        let context = ChartContext()
846
        context.setBounds(
847
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
848
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
849
            yMin: CGFloat(minimumValue),
850
            yMax: CGFloat(maximumValue)
851
        )
852
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
853

            
854
        return StoredSeriesSnapshot(
855
            points: points,
856
            context: context,
857
            minimumValue: minimumValue,
858
            maximumValue: maximumValue
859
        )
860
    }
861

            
862
    private func storedSeriesChart(
863
        title: String,
864
        unit: String,
865
        strokeColor: Color,
866
        areaChart: Bool = false,
867
        snapshot: StoredSeriesSnapshot
868
    ) -> some View {
869
        VStack(alignment: .leading, spacing: 8) {
870
            HStack(alignment: .firstTextBaseline) {
871
                Text(title)
872
                    .font(.subheadline.weight(.semibold))
873
                Spacer()
874
                Text(
875
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
876
                )
877
                .font(.caption2)
878
                .foregroundColor(.secondary)
879
            }
880

            
Bogdan Timofte authored a month ago
881
            TimeSeriesChart(
Bogdan Timofte authored a month ago
882
                points: snapshot.points,
883
                context: snapshot.context,
884
                areaChart: areaChart,
885
                strokeColor: strokeColor
886
            )
887
            .frame(height: 118)
888
            .padding(.horizontal, 6)
889
            .padding(.vertical, 8)
890
            .background(
891
                RoundedRectangle(cornerRadius: 16)
892
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
893
            )
894

            
895
            HStack {
896
                Text(snapshot.startLabel)
897
                Spacer()
898
                Text(snapshot.endLabel)
899
            }
900
            .font(.caption2)
901
            .foregroundColor(.secondary)
902
        }
903
    }
904
}
905

            
906
private struct StoredSeriesSnapshot {
907
    let points: [Measurements.Measurement.Point]
908
    let context: ChartContext
909
    let minimumValue: Double
910
    let maximumValue: Double
911

            
912
    var lastValue: Double {
913
        points.last?.value ?? 0
914
    }
915

            
916
    var startLabel: String {
917
        guard let firstTimestamp = points.first?.timestamp else { return "" }
918
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
919
    }
920

            
921
    var endLabel: String {
922
        guard let lastTimestamp = points.last?.timestamp else { return "" }
923
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
924
    }
925
}
926

            
927
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
928
    @Environment(\.dismiss) private var dismiss
929
    @EnvironmentObject private var appData: AppData
930

            
931
    let sessionID: UUID
932
    let initialTargetPercent: Double?
933

            
934
    @State private var targetPercent: Double
935

            
936
    init(sessionID: UUID, initialTargetPercent: Double?) {
937
        self.sessionID = sessionID
938
        self.initialTargetPercent = initialTargetPercent
939
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
940
    }
941

            
942
    var body: some View {
943
        NavigationView {
944
            Form {
Bogdan Timofte authored a month ago
945
                Section(
946
                    header: ContextInfoHeader(
947
                        title: "Target Level",
948
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
949
                    )
950
                ) {
Bogdan Timofte authored a month ago
951
                    VStack(alignment: .leading, spacing: 12) {
952
                        Text("\(targetPercent.format(decimalDigits: 0))%")
953
                            .font(.title3.weight(.bold))
954
                        Slider(value: $targetPercent, in: 20...100, step: 1)
955
                    }
956
                }
957
            }
958
            .navigationTitle("Battery Target")
959
            .navigationBarTitleDisplayMode(.inline)
960
            .toolbar {
961
                ToolbarItem(placement: .cancellationAction) {
962
                    Button("Cancel") {
963
                        dismiss()
964
                    }
965
                }
966

            
967
                ToolbarItem(placement: .confirmationAction) {
968
                    Button("Save") {
969
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
970
                            dismiss()
971
                        }
972
                    }
973
                }
974
            }
975
        }
976
        .navigationViewStyle(StackNavigationViewStyle())
977
    }
978
}
Bogdan Timofte authored a month ago
979

            
980
private struct DeviceSessionStopRequest: Identifiable {
981
    let sessionID: UUID
982
    let title: String
983
    let confirmTitle: String
984
    let explanation: String
985

            
986
    var id: UUID {
987
        sessionID
988
    }
989
}