USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1111 lines | 46.947kb
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
15
    @State private var pendingSessionDeletion: ChargeSessionSummary?
Bogdan Timofte authored a month ago
16
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
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: $targetNotificationEditorVisibility) {
94
            if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
95
                ChargedDeviceTargetNotificationEditorSheetView(
96
                    sessionID: activeSession.id,
97
                    initialTargetPercent: activeSession.targetBatteryPercent
98
                )
99
                .environmentObject(appData)
100
            }
101
        }
Bogdan Timofte authored a month ago
102
        .sheet(item: $pendingSessionStopRequest) { request in
103
            ChargeSessionCompletionSheetView(
104
                sessionID: request.sessionID,
105
                title: request.title,
106
                confirmTitle: request.confirmTitle,
107
                explanation: request.explanation
108
            )
109
            .environmentObject(appData)
110
        }
Bogdan Timofte authored a month ago
111
        .alert(item: $pendingSessionDeletion) { session in
112
            Alert(
113
                title: Text("Delete Session?"),
114
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
115
                primaryButton: .destructive(Text("Delete")) {
116
                    _ = appData.deleteChargeSession(sessionID: session.id)
117
                },
118
                secondaryButton: .cancel()
119
            )
120
        }
Bogdan Timofte authored a month ago
121
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
122
            Alert(
123
                title: Text("Delete Battery Checkpoint"),
124
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
125
                primaryButton: .destructive(Text("Delete")) {
126
                    _ = appData.deleteBatteryCheckpoint(
127
                        checkpointID: checkpoint.id,
128
                        for: checkpoint.sessionID
129
                    )
130
                },
131
                secondaryButton: .cancel()
132
            )
133
        }
Bogdan Timofte authored a month ago
134
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
135
            Button("Delete", role: .destructive) {
136
                if appData.deleteChargedDevice(id: chargedDeviceID) {
137
                    dismiss()
138
                }
139
            }
140
            Button("Cancel", role: .cancel) {}
141
        } message: {
142
            Text(deletionMessage)
143
        }
144
    }
145

            
146
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
147
        HStack(alignment: .top, spacing: 18) {
148
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
149

            
150
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
151
                ChargedDeviceIdentityLabelView(
152
                    chargedDevice: chargedDevice,
153
                    iconPointSize: 22
154
                )
155
                .font(.title3.weight(.bold))
Bogdan Timofte authored a month ago
156

            
Bogdan Timofte authored a month ago
157
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
158
                    .font(.subheadline.weight(.semibold))
159
                    .foregroundColor(.secondary)
160

            
161
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
162
                    Text("Default meter: \(meterMAC)")
163
                        .font(.caption)
164
                        .foregroundColor(.secondary)
165
                }
166

            
167
                Text(chargedDevice.qrIdentifier)
168
                    .font(.caption2.monospaced())
169
                    .foregroundColor(.secondary)
170
                    .textSelection(.enabled)
171
            }
172

            
173
            Spacer(minLength: 0)
174
        }
175
        .frame(maxWidth: .infinity, alignment: .leading)
176
        .padding(18)
177
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
178
    }
179

            
180
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
181
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
182
            if chargedDevice.isCharger {
183
                chargerInsights(chargedDevice)
184
            } else {
185
                deviceInsights(chargedDevice)
186
            }
187

            
188
            if let notes = chargedDevice.notes, !notes.isEmpty {
189
                Divider()
190
                Text(notes)
191
                    .font(.footnote)
192
                    .foregroundColor(.secondary)
193
                    .frame(maxWidth: .infinity, alignment: .leading)
194
            }
195
        }
196
    }
197

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

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

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

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

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

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

            
334
            if let latestMeasurement {
335
                Divider()
336

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
564
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
565
        let currentSeries = storedSeriesSnapshot(
566
            from: session.aggregatedSamples,
567
            minimumYSpan: 0.15
568
        ) { $0.averageCurrentAmps }
569
        let energySeries = storedSeriesSnapshot(
570
            from: session.aggregatedSamples,
571
            minimumYSpan: 0.2
572
        ) { $0.measuredEnergyWh }
573

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

            
590
                Spacer()
591

            
592
                Text("\(session.aggregatedSamples.count) points")
593
                    .font(.caption.weight(.semibold))
594
                    .foregroundColor(.secondary)
595
            }
596

            
597
            if let currentSeries {
598
                storedSeriesChart(
599
                    title: "Current",
600
                    unit: "A",
601
                    strokeColor: .blue,
602
                    snapshot: currentSeries
603
                )
604
            }
605

            
606
            if let energySeries {
607
                storedSeriesChart(
608
                    title: "Energy",
609
                    unit: "Wh",
610
                    strokeColor: .teal,
611
                    areaChart: true,
612
                    snapshot: energySeries
613
                )
614
            }
615

            
616
        }
617
        .frame(maxWidth: .infinity, alignment: .leading)
618
        .padding(18)
619
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
620
    }
621

            
622
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
623
        VStack(alignment: .leading, spacing: 12) {
624
            Text("Typical Charge Curve")
625
                .font(.headline)
626

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

            
647
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
648
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
649
            HStack(spacing: 8) {
650
                Text("Charge Sessions")
651
                    .font(.headline)
652
                ContextInfoButton(
653
                    title: "Charge Sessions",
654
                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
655
                )
656
            }
Bogdan Timofte authored a month ago
657

            
658
            ForEach(chargedDevice.sessions, id: \.id) { session in
659
                VStack(alignment: .leading, spacing: 6) {
660
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
661
                        Text(session.startedAt.format())
662
                            .font(.caption.weight(.semibold))
663
                        Text(session.status.title)
664
                            .font(.caption2.weight(.semibold))
665
                            .padding(.horizontal, 8)
666
                            .padding(.vertical, 4)
667
                            .background(
668
                                Capsule()
669
                                    .fill(statusTint(for: session).opacity(0.16))
670
                            )
671
                        Spacer()
672
                        Button {
673
                            pendingSessionDeletion = session
674
                        } label: {
675
                            Image(systemName: "trash")
676
                                .font(.caption.weight(.semibold))
677
                                .foregroundColor(.red)
678
                                .padding(8)
679
                                .background(
680
                                    Circle()
681
                                        .fill(Color.red.opacity(0.10))
682
                                )
683
                        }
684
                        .buttonStyle(.plain)
685
                    }
686

            
687
                    Text(sessionSummaryLine(session))
688
                        .font(.caption2)
689
                        .foregroundColor(.secondary)
690

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

            
760
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
761
        var components: [String] = []
Bogdan Timofte authored a month ago
762
        let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID)
Bogdan Timofte authored a month ago
763

            
764
        if let batteryDeltaPercent = session.batteryDeltaPercent {
765
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
766
        }
767

            
768
        if let capacityEstimateWh = session.capacityEstimateWh {
769
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
770
        }
771

            
Bogdan Timofte authored a month ago
772
        if chargedDevice?.shouldShowChargingTransport(session.chargingTransportMode) != false {
773
            components.append(session.chargingTransportMode.title)
774
        }
775
        if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
776
            components.append(session.chargingStateMode.title)
777
        }
Bogdan Timofte authored a month ago
778
        components.append(session.sourceMode.title)
779
        return components.joined(separator: " • ")
780
    }
781

            
782
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
783
        guard session.chargingTransportMode == .wireless else {
784
            return nil
785
        }
786

            
787
        var components: [String] = []
788
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
789
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
790
        }
791
        if session.usesEstimatedWirelessEfficiency {
792
            components.append("Estimated from wired baseline and checkpoints")
793
        }
794
        if session.shouldWarnAboutLowWirelessEfficiency {
795
            components.append("Low wireless efficiency, so capacity confidence is reduced")
796
        }
797

            
798
        return components.isEmpty ? nil : components.joined(separator: " • ")
799
    }
800

            
801
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
802
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
803
        let effectiveDuration = max(session.effectiveDuration, 0)
804
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
805
        formatter.unitsStyle = .abbreviated
806
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
807
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
808
    }
809

            
810
    private func statusTint(for session: ChargeSessionSummary) -> Color {
811
        switch session.status {
812
        case .active:
813
            return .green
Bogdan Timofte authored a month ago
814
        case .paused:
815
            return .orange
Bogdan Timofte authored a month ago
816
        case .completed:
817
            return .teal
818
        case .abandoned:
Bogdan Timofte authored a month ago
819
            return .secondary
Bogdan Timofte authored a month ago
820
        }
821
    }
822

            
823
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
824
        switch chargedDevice.deviceClass {
825
        case .iphone:
826
            return .blue
827
        case .watch:
828
            return .green
829
        case .powerbank:
830
            return .orange
831
        case .charger:
832
            return .pink
833
        case .other:
834
            return .secondary
835
        }
836
    }
837

            
Bogdan Timofte authored a month ago
838
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
839
        if wattHours >= 1000 {
840
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
841
        }
842
        return "\(wattHours.format(decimalDigits: 2)) Wh"
843
    }
844

            
845
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
846
        appData.meterSummaries.filter { $0.meter != nil }
847
    }
848

            
Bogdan Timofte authored a month ago
849
    private func completionCurrentDescription(
850
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
851
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
852
    ) -> String {
Bogdan Timofte authored a month ago
853
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
854
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
855
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
856
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
857
            }
858
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
859
        }
860

            
Bogdan Timofte authored a month ago
861
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
862
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
863
        }
864

            
865
        return "Learning"
866
    }
867

            
Bogdan Timofte authored a month ago
868
    private func completionCurrentLabel(
869
        for chargedDevice: ChargedDeviceSummary,
870
        sessionKind: ChargeSessionKind
871
    ) -> String {
872
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
873
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
874

            
875
        switch (showsTransport, showsState) {
876
        case (true, true):
877
            return "\(sessionKind.shortTitle) Stop Current"
878
        case (true, false):
879
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
880
        case (false, true):
881
            return "\(sessionKind.chargingStateMode.title) Stop Current"
882
        case (false, false):
883
            return "Stop Current"
884
        }
885
    }
886

            
Bogdan Timofte authored a month ago
887
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
888
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
889
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
890
                ChargeSessionKind(
891
                    chargingTransportMode: chargingTransportMode,
892
                    chargingStateMode: chargingStateMode
893
                )
894
            }
895
        }
896
    }
897

            
898
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
899
        if session.autoStopEnabled == false {
900
            return "Manual"
901
        }
902

            
903
        if let sessionWarning = sessionWarning(for: session),
904
           sessionWarning.contains("idle-current") {
905
            return "Blocked by charger setup"
906
        }
907

            
908
        if session.stopThresholdAmps > 0 {
909
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
910
        }
911

            
912
        return "Learning"
913
    }
914

            
915
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
916
        guard session.chargingTransportMode == .wireless,
917
              let chargerID = session.chargerID,
918
              let charger = appData.chargedDeviceSummary(id: chargerID),
919
              charger.chargerIdleCurrentAmps == nil else {
920
            return nil
921
        }
922

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

            
Bogdan Timofte authored a month ago
926
    private var deletionTitle: String {
927
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
928
    }
929

            
930
    private var deletionMessage: String {
931
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
932
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
933
        }
934
        return "This removes the device and its stored charging history from the library."
935
    }
936

            
937
    private func storedSeriesSnapshot(
938
        from samples: [ChargeSessionSampleSummary],
939
        minimumYSpan: Double,
940
        value: (ChargeSessionSampleSummary) -> Double
941
    ) -> StoredSeriesSnapshot? {
942
        let sortedSamples = samples.sorted { lhs, rhs in
943
            if lhs.bucketIndex != rhs.bucketIndex {
944
                return lhs.bucketIndex < rhs.bucketIndex
945
            }
946
            return lhs.timestamp < rhs.timestamp
947
        }
948

            
949
        guard
950
            let firstSample = sortedSamples.first,
951
            let lastSample = sortedSamples.last
952
        else {
953
            return nil
954
        }
955

            
956
        let points = sortedSamples.enumerated().map { index, sample in
957
            Measurements.Measurement.Point(
958
                id: index,
959
                timestamp: sample.timestamp,
960
                value: value(sample),
961
                kind: .sample
962
            )
963
        }
964

            
965
        let minimumValue = points.map(\.value).min() ?? 0
966
        let maximumValue = points.map(\.value).max() ?? minimumValue
967
        let context = ChartContext()
968
        context.setBounds(
969
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
970
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
971
            yMin: CGFloat(minimumValue),
972
            yMax: CGFloat(maximumValue)
973
        )
974
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
975

            
976
        return StoredSeriesSnapshot(
977
            points: points,
978
            context: context,
979
            minimumValue: minimumValue,
980
            maximumValue: maximumValue
981
        )
982
    }
983

            
984
    private func storedSeriesChart(
985
        title: String,
986
        unit: String,
987
        strokeColor: Color,
988
        areaChart: Bool = false,
989
        snapshot: StoredSeriesSnapshot
990
    ) -> some View {
991
        VStack(alignment: .leading, spacing: 8) {
992
            HStack(alignment: .firstTextBaseline) {
993
                Text(title)
994
                    .font(.subheadline.weight(.semibold))
995
                Spacer()
996
                Text(
997
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
998
                )
999
                .font(.caption2)
1000
                .foregroundColor(.secondary)
1001
            }
1002

            
1003
            Chart(
1004
                points: snapshot.points,
1005
                context: snapshot.context,
1006
                areaChart: areaChart,
1007
                strokeColor: strokeColor
1008
            )
1009
            .frame(height: 118)
1010
            .padding(.horizontal, 6)
1011
            .padding(.vertical, 8)
1012
            .background(
1013
                RoundedRectangle(cornerRadius: 16)
1014
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
1015
            )
1016

            
1017
            HStack {
1018
                Text(snapshot.startLabel)
1019
                Spacer()
1020
                Text(snapshot.endLabel)
1021
            }
1022
            .font(.caption2)
1023
            .foregroundColor(.secondary)
1024
        }
1025
    }
1026
}
1027

            
1028
private struct StoredSeriesSnapshot {
1029
    let points: [Measurements.Measurement.Point]
1030
    let context: ChartContext
1031
    let minimumValue: Double
1032
    let maximumValue: Double
1033

            
1034
    var lastValue: Double {
1035
        points.last?.value ?? 0
1036
    }
1037

            
1038
    var startLabel: String {
1039
        guard let firstTimestamp = points.first?.timestamp else { return "" }
1040
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
1041
    }
1042

            
1043
    var endLabel: String {
1044
        guard let lastTimestamp = points.last?.timestamp else { return "" }
1045
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
1046
    }
1047
}
1048

            
1049
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1050
    @Environment(\.dismiss) private var dismiss
1051
    @EnvironmentObject private var appData: AppData
1052

            
1053
    let sessionID: UUID
1054
    let initialTargetPercent: Double?
1055

            
1056
    @State private var targetPercent: Double
1057

            
1058
    init(sessionID: UUID, initialTargetPercent: Double?) {
1059
        self.sessionID = sessionID
1060
        self.initialTargetPercent = initialTargetPercent
1061
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1062
    }
1063

            
1064
    var body: some View {
1065
        NavigationView {
1066
            Form {
Bogdan Timofte authored a month ago
1067
                Section(
1068
                    header: ContextInfoHeader(
1069
                        title: "Target Level",
1070
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
1071
                    )
1072
                ) {
Bogdan Timofte authored a month ago
1073
                    VStack(alignment: .leading, spacing: 12) {
1074
                        Text("\(targetPercent.format(decimalDigits: 0))%")
1075
                            .font(.title3.weight(.bold))
1076
                        Slider(value: $targetPercent, in: 20...100, step: 1)
1077
                    }
1078
                }
1079
            }
1080
            .navigationTitle("Battery Target")
1081
            .navigationBarTitleDisplayMode(.inline)
1082
            .toolbar {
1083
                ToolbarItem(placement: .cancellationAction) {
1084
                    Button("Cancel") {
1085
                        dismiss()
1086
                    }
1087
                }
1088

            
1089
                ToolbarItem(placement: .confirmationAction) {
1090
                    Button("Save") {
1091
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
1092
                            dismiss()
1093
                        }
1094
                    }
1095
                }
1096
            }
1097
        }
1098
        .navigationViewStyle(StackNavigationViewStyle())
1099
    }
1100
}
Bogdan Timofte authored a month ago
1101

            
1102
private struct DeviceSessionStopRequest: Identifiable {
1103
    let sessionID: UUID
1104
    let title: String
1105
    let confirmTitle: String
1106
    let explanation: String
1107

            
1108
    var id: UUID {
1109
        sessionID
1110
    }
1111
}