USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1187 lines | 49.212kb
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

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

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

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

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

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

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

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

            
143
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
144
                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
145
                    .font(.title3.weight(.bold))
146

            
Bogdan Timofte authored a month ago
147
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
148
                    .font(.subheadline.weight(.semibold))
149
                    .foregroundColor(.secondary)
150

            
151
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
152
                    Text("Default meter: \(meterMAC)")
153
                        .font(.caption)
154
                        .foregroundColor(.secondary)
155
                }
156

            
157
                Text(chargedDevice.qrIdentifier)
158
                    .font(.caption2.monospaced())
159
                    .foregroundColor(.secondary)
160
                    .textSelection(.enabled)
161
            }
162

            
163
            Spacer(minLength: 0)
164
        }
165
        .frame(maxWidth: .infinity, alignment: .leading)
166
        .padding(18)
167
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
168
    }
169

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

            
178
            if let notes = chargedDevice.notes, !notes.isEmpty {
179
                Divider()
180
                Text(notes)
181
                    .font(.footnote)
182
                    .foregroundColor(.secondary)
183
                    .frame(maxWidth: .infinity, alignment: .leading)
184
            }
185
        }
186
    }
187

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

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

            
Bogdan Timofte authored a month ago
243
    @ViewBuilder
244
    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
245
        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
Bogdan Timofte authored a month ago
246
            MeterInfoRowView(
Bogdan Timofte authored a month ago
247
                label: "Observed Voltages",
248
                value: chargedDevice.chargerObservedVoltageSelections
249
                    .map { "\($0.format(decimalDigits: 1)) V" }
250
                    .joined(separator: ", ")
Bogdan Timofte authored a month ago
251
            )
Bogdan Timofte authored a month ago
252
        }
253
        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
Bogdan Timofte authored a month ago
254
            MeterInfoRowView(
Bogdan Timofte authored a month ago
255
                label: "Idle Current",
256
                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
Bogdan Timofte authored a month ago
257
            )
Bogdan Timofte authored a month ago
258
        }
259
        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
Bogdan Timofte authored a month ago
260
            MeterInfoRowView(
Bogdan Timofte authored a month ago
261
                label: "Efficiency",
262
                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
Bogdan Timofte authored a month ago
263
            )
Bogdan Timofte authored a month ago
264
        }
265
        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
266
            MeterInfoRowView(
267
                label: "Max Power",
268
                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
269
            )
270
        }
Bogdan Timofte authored a month ago
271
        if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
272
            MeterInfoRowView(
273
                label: "Standby Power",
274
                value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W"
275
            )
276
            MeterInfoRowView(
277
                label: "Standby Projection",
278
                value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year"
279
            )
280
        }
Bogdan Timofte authored a month ago
281
        MeterInfoRowView(
282
            label: "Wireless Sessions",
283
            value: "\(chargedDevice.sessionCount)"
284
        )
Bogdan Timofte authored a month ago
285

            
Bogdan Timofte authored a month ago
286
        if chargedDevice.chargerIdleCurrentAmps == nil {
287
            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.")
288
                .font(.caption2)
289
                .foregroundColor(.orange)
Bogdan Timofte authored a month ago
290
        }
291
    }
292

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

            
296
        return MeterInfoCardView(
297
            title: "Standby Power",
298
            tint: .orange
299
        ) {
300
            if standbyMeasurementMeters.isEmpty {
301
                Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
302
                    .font(.footnote)
303
                    .foregroundColor(.secondary)
304
                    .frame(maxWidth: .infinity, alignment: .leading)
305
            } else {
306
                NavigationLink(
307
                    destination: ChargerStandbyPowerWizardView(
308
                        preferredChargerID: chargedDevice.id,
309
                        locksChargerSelection: true
310
                    )
311
                ) {
312
                    Label("New Measurement", systemImage: "plus.circle.fill")
313
                        .font(.subheadline.weight(.semibold))
314
                        .foregroundColor(.orange)
315
                }
316
                .buttonStyle(.plain)
317
            }
318

            
319
            if let latestMeasurement {
320
                Divider()
321

            
322
                NavigationLink(
323
                    destination: ChargerStandbyPowerMeasurementDetailView(
324
                        chargerID: chargedDevice.id,
325
                        measurementID: latestMeasurement.id
326
                    )
327
                ) {
328
                    VStack(alignment: .leading, spacing: 8) {
329
                        HStack {
330
                            Text("Latest Measurement")
331
                                .font(.subheadline.weight(.semibold))
332
                                .foregroundColor(.primary)
333
                            Spacer()
334
                            Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
335
                                .font(.subheadline.weight(.bold))
336
                                .foregroundColor(.primary)
337
                                .monospacedDigit()
338
                        }
339

            
340
                        Text(
341
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
342
                        )
343
                        .font(.caption)
344
                        .foregroundColor(.secondary)
345
                    }
346
                }
347
                .buttonStyle(.plain)
348
            }
349

            
350
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
351
                Divider()
352

            
353
                NavigationLink(
354
                    destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
355
                ) {
356
                    Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
357
                        .font(.subheadline.weight(.semibold))
358
                        .foregroundColor(.blue)
359
                }
360
                .buttonStyle(.plain)
361

            
362
                Divider()
363

            
364
                ForEach(Array(chargedDevice.standbyPowerMeasurements.prefix(3))) { measurement in
365
                    NavigationLink(
366
                        destination: ChargerStandbyPowerMeasurementDetailView(
367
                            chargerID: chargedDevice.id,
368
                            measurementID: measurement.id
369
                        )
370
                    ) {
371
                        HStack {
372
                            VStack(alignment: .leading, spacing: 4) {
373
                                Text(measurement.endedAt.format())
374
                                    .font(.subheadline.weight(.semibold))
375
                                    .foregroundColor(.primary)
376
                                Text("\(measurement.sampleCount) samples")
377
                                    .font(.caption)
378
                                    .foregroundColor(.secondary)
379
                            }
380

            
381
                            Spacer()
382

            
383
                            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
384
                                .font(.subheadline.weight(.bold))
385
                                .foregroundColor(.primary)
386
                                .monospacedDigit()
387
                        }
388
                    }
389
                    .buttonStyle(.plain)
390

            
391
                    if measurement.id != chargedDevice.standbyPowerMeasurements.prefix(3).last?.id {
392
                        Divider()
393
                    }
394
                }
395
            }
396
        }
397
    }
398

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

            
462
            Button("Add Battery Checkpoint") {
463
                checkpointEditorVisibility = true
464
            }
465
            .frame(maxWidth: .infinity)
466
            .padding(.vertical, 10)
467
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
468
            .buttonStyle(.plain)
469

            
470
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
471
                targetNotificationEditorVisibility = true
472
            }
473
            .frame(maxWidth: .infinity)
474
            .padding(.vertical, 10)
475
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
476
            .buttonStyle(.plain)
477

            
478
            if activeSession.targetBatteryPercent != nil {
479
                Button("Clear Target Notification") {
480
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
481
                }
482
                .frame(maxWidth: .infinity)
483
                .padding(.vertical, 10)
484
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
485
                .buttonStyle(.plain)
486
            }
487

            
Bogdan Timofte authored a month ago
488
            if activeSession.status == .active {
489
                Button("Pause Session") {
490
                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
491
                }
492
                .frame(maxWidth: .infinity)
493
                .padding(.vertical, 10)
494
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
495
                .buttonStyle(.plain)
496
            } else if activeSession.status == .paused {
497
                Button("Resume Session") {
498
                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
499
                }
500
                .frame(maxWidth: .infinity)
501
                .padding(.vertical, 10)
502
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
503
                .buttonStyle(.plain)
504

            
505
                Text("Paused sessions close automatically after 10 minutes.")
506
                    .font(.caption2)
507
                    .foregroundColor(.secondary)
508
            }
509

            
510
            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
511
                pendingSessionStopRequest = DeviceSessionStopRequest(
512
                    sessionID: activeSession.id,
513
                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
514
                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
515
                    explanation: "Add the final battery checkpoint before closing this session."
516
                )
Bogdan Timofte authored a month ago
517
            }
518
            .frame(maxWidth: .infinity)
519
            .padding(.vertical, 10)
Bogdan Timofte authored a month ago
520
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
Bogdan Timofte authored a month ago
521
            .buttonStyle(.plain)
522

            
523
            if activeSession.requiresCompletionConfirmation {
524
                Divider()
525
                if let contradictionPercent = activeSession.completionContradictionPercent {
526
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
527
                        .font(.caption2)
528
                        .foregroundColor(.secondary)
529
                }
530

            
531
                Button("Keep Monitoring") {
532
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
533
                }
534
                .frame(maxWidth: .infinity)
535
                .padding(.vertical, 10)
536
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
537
                .buttonStyle(.plain)
538
            }
539
        }
540
    }
541

            
542
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
543
        VStack(alignment: .leading, spacing: 12) {
544
            Text("Capacity Evolution")
545
                .font(.headline)
546

            
547
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
548
                HStack {
549
                    Text(point.timestamp.format())
550
                        .font(.caption)
551
                        .foregroundColor(.secondary)
552
                    Spacer()
553
                    Text(point.chargingTransportMode.title)
554
                        .font(.caption2)
555
                        .foregroundColor(.secondary)
556
                    Text("•")
557
                        .foregroundColor(.secondary)
558
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
559
                        .font(.footnote.weight(.semibold))
560
                }
561
            }
562
        }
563
        .frame(maxWidth: .infinity, alignment: .leading)
564
        .padding(18)
565
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
566
    }
567

            
568
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
569
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
570
            return activeSession
571
        }
572

            
573
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
574
    }
575

            
576
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
577
        let currentSeries = storedSeriesSnapshot(
578
            from: session.aggregatedSamples,
579
            minimumYSpan: 0.15
580
        ) { $0.averageCurrentAmps }
581
        let energySeries = storedSeriesSnapshot(
582
            from: session.aggregatedSamples,
583
            minimumYSpan: 0.2
584
        ) { $0.measuredEnergyWh }
585

            
586
        return VStack(alignment: .leading, spacing: 14) {
587
            HStack(alignment: .firstTextBaseline) {
588
                VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
589
                    HStack(spacing: 8) {
590
                        Text("Stored Session Curve")
591
                            .font(.headline)
592
                        ContextInfoButton(
593
                            title: "Stored Session Curve",
594
                            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."
595
                        )
596
                    }
Bogdan Timofte authored a month ago
597
                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
Bogdan Timofte authored a month ago
598
                        .font(.caption)
599
                        .foregroundColor(.secondary)
600
                }
601

            
602
                Spacer()
603

            
604
                Text("\(session.aggregatedSamples.count) points")
605
                    .font(.caption.weight(.semibold))
606
                    .foregroundColor(.secondary)
607
            }
608

            
609
            if let currentSeries {
610
                storedSeriesChart(
611
                    title: "Current",
612
                    unit: "A",
613
                    strokeColor: .blue,
614
                    snapshot: currentSeries
615
                )
616
            }
617

            
618
            if let energySeries {
619
                storedSeriesChart(
620
                    title: "Energy",
621
                    unit: "Wh",
622
                    strokeColor: .teal,
623
                    areaChart: true,
624
                    snapshot: energySeries
625
                )
626
            }
627

            
628
        }
629
        .frame(maxWidth: .infinity, alignment: .leading)
630
        .padding(18)
631
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
632
    }
633

            
634
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
635
        VStack(alignment: .leading, spacing: 12) {
636
            Text("Typical Charge Curve")
637
                .font(.headline)
638

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

            
659
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
660
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
661
            HStack(spacing: 8) {
662
                Text("Charge Sessions")
663
                    .font(.headline)
664
                ContextInfoButton(
665
                    title: "Charge Sessions",
666
                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
667
                )
668
            }
Bogdan Timofte authored a month ago
669

            
670
            ForEach(chargedDevice.sessions, id: \.id) { session in
671
                VStack(alignment: .leading, spacing: 6) {
672
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
673
                        Text(session.startedAt.format())
674
                            .font(.caption.weight(.semibold))
675
                        Text(session.status.title)
676
                            .font(.caption2.weight(.semibold))
677
                            .padding(.horizontal, 8)
678
                            .padding(.vertical, 4)
679
                            .background(
680
                                Capsule()
681
                                    .fill(statusTint(for: session).opacity(0.16))
682
                            )
683
                        Spacer()
684
                        Button {
685
                            pendingSessionDeletion = session
686
                        } label: {
687
                            Image(systemName: "trash")
688
                                .font(.caption.weight(.semibold))
689
                                .foregroundColor(.red)
690
                                .padding(8)
691
                                .background(
692
                                    Circle()
693
                                        .fill(Color.red.opacity(0.10))
694
                                )
695
                        }
696
                        .buttonStyle(.plain)
697
                    }
698

            
699
                    Text(sessionSummaryLine(session))
700
                        .font(.caption2)
701
                        .foregroundColor(.secondary)
702

            
703
                    MeterInfoRowView(
704
                        label: "Duration",
705
                        value: sessionDurationText(session)
706
                    )
707
                    MeterInfoRowView(
708
                        label: "Energy",
709
                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
710
                    )
711
                    if session.chargingTransportMode == .wireless,
712
                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
713
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
714
                        MeterInfoRowView(
715
                            label: "Charger Energy",
716
                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
717
                        )
718
                    }
719
                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
720
                        MeterInfoRowView(
721
                            label: "Max Current",
722
                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
723
                        )
724
                    }
725
                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
726
                        MeterInfoRowView(
727
                            label: "Max Power",
728
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
729
                        )
730
                    }
731
                    if session.chargingTransportMode == .wired,
732
                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
733
                        MeterInfoRowView(
734
                            label: "Max Voltage",
735
                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
736
                        )
737
                    }
738
                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
739
                        MeterInfoRowView(
740
                            label: "Selected Voltage",
741
                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
742
                        )
743
                    }
744
                    if chargedDevice.isCharger == false,
745
                       let chargerID = session.chargerID,
746
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
747
                        MeterInfoRowView(
748
                            label: "Wireless Charger",
749
                            value: charger.name
750
                        )
751
                    }
752
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
753
                        Text(wirelessSessionHint)
754
                            .font(.caption2)
755
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
756
                    }
757
                }
758
                .padding(14)
759
                .meterCard(
760
                    tint: statusTint(for: session),
761
                    fillOpacity: 0.10,
762
                    strokeOpacity: 0.16,
763
                    cornerRadius: 16
764
                )
765
            }
766
        }
767
        .frame(maxWidth: .infinity, alignment: .leading)
768
        .padding(18)
769
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
770
    }
771

            
772
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
773
        var components: [String] = []
774

            
775
        if let batteryDeltaPercent = session.batteryDeltaPercent {
776
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
777
        }
778

            
779
        if let capacityEstimateWh = session.capacityEstimateWh {
780
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
781
        }
782

            
783
        components.append(session.chargingTransportMode.title)
Bogdan Timofte authored a month ago
784
        components.append(session.chargingStateMode.title)
Bogdan Timofte authored a month ago
785
        components.append(session.sourceMode.title)
786
        return components.joined(separator: " • ")
787
    }
788

            
789
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
790
        guard session.chargingTransportMode == .wireless else {
791
            return nil
792
        }
793

            
794
        var components: [String] = []
795
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
796
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
797
        }
798
        if session.usesEstimatedWirelessEfficiency {
799
            components.append("Estimated from wired baseline and checkpoints")
800
        }
801
        if session.shouldWarnAboutLowWirelessEfficiency {
802
            components.append("Low wireless efficiency, so capacity confidence is reduced")
803
        }
804

            
805
        return components.isEmpty ? nil : components.joined(separator: " • ")
806
    }
807

            
808
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
809
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
810
        let effectiveDuration = max(session.effectiveDuration, 0)
811
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
812
        formatter.unitsStyle = .abbreviated
813
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
814
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
815
    }
816

            
817
    private func statusTint(for session: ChargeSessionSummary) -> Color {
818
        switch session.status {
819
        case .active:
820
            return .green
Bogdan Timofte authored a month ago
821
        case .paused:
822
            return .orange
Bogdan Timofte authored a month ago
823
        case .completed:
824
            return .teal
825
        case .abandoned:
Bogdan Timofte authored a month ago
826
            return .secondary
Bogdan Timofte authored a month ago
827
        }
828
    }
829

            
830
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
831
        switch chargedDevice.deviceClass {
832
        case .iphone:
833
            return .blue
834
        case .watch:
835
            return .green
836
        case .powerbank:
837
            return .orange
838
        case .charger:
839
            return .pink
840
        case .other:
841
            return .secondary
842
        }
843
    }
844

            
Bogdan Timofte authored a month ago
845
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
846
        if wattHours >= 1000 {
847
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
848
        }
849
        return "\(wattHours.format(decimalDigits: 2)) Wh"
850
    }
851

            
852
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
853
        appData.meterSummaries.filter { $0.meter != nil }
854
    }
855

            
Bogdan Timofte authored a month ago
856
    private func completionCurrentDescription(
857
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
858
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
859
    ) -> String {
Bogdan Timofte authored a month ago
860
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
861
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
862
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
863
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
864
            }
865
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
866
        }
867

            
Bogdan Timofte authored a month ago
868
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
869
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
870
        }
871

            
872
        return "Learning"
873
    }
874

            
Bogdan Timofte authored a month ago
875
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
876
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
877
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
878
                ChargeSessionKind(
879
                    chargingTransportMode: chargingTransportMode,
880
                    chargingStateMode: chargingStateMode
881
                )
882
            }
883
        }
884
    }
885

            
886
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
887
        if session.autoStopEnabled == false {
888
            return "Manual"
889
        }
890

            
891
        if let sessionWarning = sessionWarning(for: session),
892
           sessionWarning.contains("idle-current") {
893
            return "Blocked by charger setup"
894
        }
895

            
896
        if session.stopThresholdAmps > 0 {
897
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
898
        }
899

            
900
        return "Learning"
901
    }
902

            
903
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
904
        guard session.chargingTransportMode == .wireless,
905
              let chargerID = session.chargerID,
906
              let charger = appData.chargedDeviceSummary(id: chargerID),
907
              charger.chargerIdleCurrentAmps == nil else {
908
            return nil
909
        }
910

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

            
Bogdan Timofte authored a month ago
914
    private var deletionTitle: String {
915
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
916
    }
917

            
918
    private var deletionMessage: String {
919
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
920
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
921
        }
922
        return "This removes the device and its stored charging history from the library."
923
    }
924

            
925
    private func storedSeriesSnapshot(
926
        from samples: [ChargeSessionSampleSummary],
927
        minimumYSpan: Double,
928
        value: (ChargeSessionSampleSummary) -> Double
929
    ) -> StoredSeriesSnapshot? {
930
        let sortedSamples = samples.sorted { lhs, rhs in
931
            if lhs.bucketIndex != rhs.bucketIndex {
932
                return lhs.bucketIndex < rhs.bucketIndex
933
            }
934
            return lhs.timestamp < rhs.timestamp
935
        }
936

            
937
        guard
938
            let firstSample = sortedSamples.first,
939
            let lastSample = sortedSamples.last
940
        else {
941
            return nil
942
        }
943

            
944
        let points = sortedSamples.enumerated().map { index, sample in
945
            Measurements.Measurement.Point(
946
                id: index,
947
                timestamp: sample.timestamp,
948
                value: value(sample),
949
                kind: .sample
950
            )
951
        }
952

            
953
        let minimumValue = points.map(\.value).min() ?? 0
954
        let maximumValue = points.map(\.value).max() ?? minimumValue
955
        let context = ChartContext()
956
        context.setBounds(
957
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
958
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
959
            yMin: CGFloat(minimumValue),
960
            yMax: CGFloat(maximumValue)
961
        )
962
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
963

            
964
        return StoredSeriesSnapshot(
965
            points: points,
966
            context: context,
967
            minimumValue: minimumValue,
968
            maximumValue: maximumValue
969
        )
970
    }
971

            
972
    private func storedSeriesChart(
973
        title: String,
974
        unit: String,
975
        strokeColor: Color,
976
        areaChart: Bool = false,
977
        snapshot: StoredSeriesSnapshot
978
    ) -> some View {
979
        VStack(alignment: .leading, spacing: 8) {
980
            HStack(alignment: .firstTextBaseline) {
981
                Text(title)
982
                    .font(.subheadline.weight(.semibold))
983
                Spacer()
984
                Text(
985
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
986
                )
987
                .font(.caption2)
988
                .foregroundColor(.secondary)
989
            }
990

            
991
            Chart(
992
                points: snapshot.points,
993
                context: snapshot.context,
994
                areaChart: areaChart,
995
                strokeColor: strokeColor
996
            )
997
            .frame(height: 118)
998
            .padding(.horizontal, 6)
999
            .padding(.vertical, 8)
1000
            .background(
1001
                RoundedRectangle(cornerRadius: 16)
1002
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
1003
            )
1004

            
1005
            HStack {
1006
                Text(snapshot.startLabel)
1007
                Spacer()
1008
                Text(snapshot.endLabel)
1009
            }
1010
            .font(.caption2)
1011
            .foregroundColor(.secondary)
1012
        }
1013
    }
1014
}
1015

            
1016
private struct StoredSeriesSnapshot {
1017
    let points: [Measurements.Measurement.Point]
1018
    let context: ChartContext
1019
    let minimumValue: Double
1020
    let maximumValue: Double
1021

            
1022
    var lastValue: Double {
1023
        points.last?.value ?? 0
1024
    }
1025

            
1026
    var startLabel: String {
1027
        guard let firstTimestamp = points.first?.timestamp else { return "" }
1028
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
1029
    }
1030

            
1031
    var endLabel: String {
1032
        guard let lastTimestamp = points.last?.timestamp else { return "" }
1033
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
1034
    }
1035
}
1036

            
1037
private struct ChargedDeviceCheckpointEditorSheetView: View {
1038
    @Environment(\.dismiss) private var dismiss
1039
    @EnvironmentObject private var appData: AppData
1040

            
1041
    let sessionID: UUID
1042

            
1043
    @State private var batteryPercent = ""
1044
    @State private var label = ""
Bogdan Timofte authored a month ago
1045
    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
1046

            
1047
    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
1048
        guard let percent = Double(batteryPercent) else {
1049
            return nil
1050
        }
1051
        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID)
1052
    }
Bogdan Timofte authored a month ago
1053

            
1054
    var body: some View {
1055
        NavigationView {
1056
            Form {
Bogdan Timofte authored a month ago
1057
                Section(
1058
                    header: ContextInfoHeader(
1059
                        title: "Checkpoint",
1060
                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
1061
                    )
1062
                ) {
Bogdan Timofte authored a month ago
1063
                    TextField("Battery %", text: $batteryPercent)
1064
                        .keyboardType(.decimalPad)
1065
                    TextField("Label (optional)", text: $label)
1066
                }
1067

            
Bogdan Timofte authored a month ago
1068
                if let plausibilityWarning {
1069
                    Section(header: Text(plausibilityWarning.title)) {
1070
                        Text(plausibilityWarning.message)
1071
                            .font(.footnote)
1072
                            .foregroundColor(.orange)
1073
                    }
Bogdan Timofte authored a month ago
1074
                }
1075
            }
1076
            .navigationTitle("Battery Checkpoint")
1077
            .navigationBarTitleDisplayMode(.inline)
1078
            .toolbar {
1079
                ToolbarItem(placement: .cancellationAction) {
1080
                    Button("Cancel") {
1081
                        dismiss()
1082
                    }
1083
                }
1084

            
1085
                ToolbarItem(placement: .confirmationAction) {
1086
                    Button("Save") {
Bogdan Timofte authored a month ago
1087
                        saveCheckpoint()
Bogdan Timofte authored a month ago
1088
                    }
1089
                    .disabled(
1090
                        (Double(batteryPercent) ?? -1) < 0
1091
                            || (Double(batteryPercent) ?? 101) > 100
1092
                    )
1093
                }
1094
            }
1095
        }
1096
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
1097
        .alert(item: $confirmationWarning) { warning in
1098
            Alert(
1099
                title: Text(warning.title),
1100
                message: Text(warning.message),
1101
                primaryButton: .destructive(Text("Save Anyway")) {
1102
                    saveCheckpoint(forceOverride: true)
1103
                },
1104
                secondaryButton: .cancel()
1105
            )
1106
        }
1107
    }
1108

            
1109
    private func saveCheckpoint(forceOverride: Bool = false) {
1110
        guard let percent = Double(batteryPercent) else {
1111
            return
1112
        }
1113

            
1114
        if !forceOverride, let plausibilityWarning {
1115
            confirmationWarning = plausibilityWarning
1116
            return
1117
        }
1118

            
1119
        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
1120
            dismiss()
1121
        }
Bogdan Timofte authored a month ago
1122
    }
1123
}
1124

            
1125
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1126
    @Environment(\.dismiss) private var dismiss
1127
    @EnvironmentObject private var appData: AppData
1128

            
1129
    let sessionID: UUID
1130
    let initialTargetPercent: Double?
1131

            
1132
    @State private var targetPercent: Double
1133

            
1134
    init(sessionID: UUID, initialTargetPercent: Double?) {
1135
        self.sessionID = sessionID
1136
        self.initialTargetPercent = initialTargetPercent
1137
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1138
    }
1139

            
1140
    var body: some View {
1141
        NavigationView {
1142
            Form {
Bogdan Timofte authored a month ago
1143
                Section(
1144
                    header: ContextInfoHeader(
1145
                        title: "Target Level",
1146
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
1147
                    )
1148
                ) {
Bogdan Timofte authored a month ago
1149
                    VStack(alignment: .leading, spacing: 12) {
1150
                        Text("\(targetPercent.format(decimalDigits: 0))%")
1151
                            .font(.title3.weight(.bold))
1152
                        Slider(value: $targetPercent, in: 20...100, step: 1)
1153
                    }
1154
                }
1155
            }
1156
            .navigationTitle("Battery Target")
1157
            .navigationBarTitleDisplayMode(.inline)
1158
            .toolbar {
1159
                ToolbarItem(placement: .cancellationAction) {
1160
                    Button("Cancel") {
1161
                        dismiss()
1162
                    }
1163
                }
1164

            
1165
                ToolbarItem(placement: .confirmationAction) {
1166
                    Button("Save") {
1167
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
1168
                            dismiss()
1169
                        }
1170
                    }
1171
                }
1172
            }
1173
        }
1174
        .navigationViewStyle(StackNavigationViewStyle())
1175
    }
1176
}
Bogdan Timofte authored a month ago
1177

            
1178
private struct DeviceSessionStopRequest: Identifiable {
1179
    let sessionID: UUID
1180
    let title: String
1181
    let confirmTitle: String
1182
    let explanation: String
1183

            
1184
    var id: UUID {
1185
        sessionID
1186
    }
1187
}