USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1125 lines | 47.379kb
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
Bogdan Timofte authored a month ago
19
    @State private var showsInlineCheckpointEditor = false
Bogdan Timofte authored a month ago
20

            
21
    let chargedDeviceID: UUID
22

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

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

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

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

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

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

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

            
150
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
151
        HStack(alignment: .top, spacing: 18) {
152
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
153

            
154
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
155
                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
156
                    .font(.title3.weight(.bold))
157

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

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

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

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

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

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

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

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

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

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

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

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

            
330
            if let latestMeasurement {
331
                Divider()
332

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

            
351
                        Text(
352
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
353
                        )
354
                        .font(.caption)
355
                        .foregroundColor(.secondary)
356
                    }
357
                }
358
                .buttonStyle(.plain)
359
            }
360

            
361
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
362
                Divider()
363

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

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

            
Bogdan Timofte authored a month ago
439
            if !activeSession.checkpoints.isEmpty {
440
                checkpointList(
441
                    checkpoints: Array(activeSession.checkpoints.suffix(6).reversed())
442
                )
443
            }
444

            
445
            Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
446
                showsInlineCheckpointEditor.toggle()
Bogdan Timofte authored a month ago
447
            }
448
            .frame(maxWidth: .infinity)
449
            .padding(.vertical, 10)
450
            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
451
            .buttonStyle(.plain)
452

            
Bogdan Timofte authored a month ago
453
            if showsInlineCheckpointEditor {
454
                BatteryCheckpointEditorContentView(
455
                    sessionID: activeSession.id,
456
                    message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
457
                    effectiveEnergyWhOverride: nil,
458
                    measuredChargeAhOverride: nil,
459
                    onCancel: { showsInlineCheckpointEditor = false },
460
                    onSaved: { showsInlineCheckpointEditor = false }
461
                )
462
            }
463

            
Bogdan Timofte authored a month ago
464
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
465
                targetNotificationEditorVisibility = true
466
            }
467
            .frame(maxWidth: .infinity)
468
            .padding(.vertical, 10)
469
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
470
            .buttonStyle(.plain)
471

            
472
            if activeSession.targetBatteryPercent != nil {
473
                Button("Clear Target Notification") {
474
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
475
                }
476
                .frame(maxWidth: .infinity)
477
                .padding(.vertical, 10)
478
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
479
                .buttonStyle(.plain)
480
            }
481

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

            
499
                Text("Paused sessions close automatically after 10 minutes.")
500
                    .font(.caption2)
501
                    .foregroundColor(.secondary)
502
            }
503

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

            
517
            if activeSession.requiresCompletionConfirmation {
518
                Divider()
519
                if let contradictionPercent = activeSession.completionContradictionPercent {
520
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
521
                        .font(.caption2)
522
                        .foregroundColor(.secondary)
523
                }
524

            
525
                Button("Keep Monitoring") {
526
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
527
                }
528
                .frame(maxWidth: .infinity)
529
                .padding(.vertical, 10)
530
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
531
                .buttonStyle(.plain)
532
            }
533
        }
534
    }
535

            
536
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
537
        VStack(alignment: .leading, spacing: 12) {
538
            Text("Capacity Evolution")
539
                .font(.headline)
540

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

            
Bogdan Timofte authored a month ago
562
    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
563
        VStack(alignment: .leading, spacing: 8) {
564
            Text("Battery Checkpoints")
565
                .font(.subheadline.weight(.semibold))
566

            
567
            ForEach(checkpoints, id: \.id) { checkpoint in
568
                HStack {
569
                    Text(checkpoint.timestamp.format())
570
                        .font(.caption2)
571
                        .foregroundColor(.secondary)
572
                    Spacer()
573
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
574
                        .font(.caption.weight(.semibold))
575
                    Text("•")
576
                        .foregroundColor(.secondary)
577
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
578
                        .font(.caption2)
579
                        .foregroundColor(.secondary)
580
                    Button {
581
                        pendingCheckpointDeletion = checkpoint
582
                    } label: {
583
                        Image(systemName: "trash")
584
                            .font(.caption.weight(.semibold))
585
                            .foregroundColor(.red)
586
                    }
587
                    .buttonStyle(.plain)
588
                    .help("Delete checkpoint")
589
                }
590
            }
591
        }
592
    }
593

            
Bogdan Timofte authored a month ago
594
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
595
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
596
            return activeSession
597
        }
598

            
599
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
600
    }
601

            
602
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
603
        let currentSeries = storedSeriesSnapshot(
604
            from: session.aggregatedSamples,
605
            minimumYSpan: 0.15
606
        ) { $0.averageCurrentAmps }
607
        let energySeries = storedSeriesSnapshot(
608
            from: session.aggregatedSamples,
609
            minimumYSpan: 0.2
610
        ) { $0.measuredEnergyWh }
611

            
612
        return VStack(alignment: .leading, spacing: 14) {
613
            HStack(alignment: .firstTextBaseline) {
614
                VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
615
                    HStack(spacing: 8) {
616
                        Text("Stored Session Curve")
617
                            .font(.headline)
618
                        ContextInfoButton(
619
                            title: "Stored Session Curve",
620
                            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."
621
                        )
622
                    }
Bogdan Timofte authored a month ago
623
                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
Bogdan Timofte authored a month ago
624
                        .font(.caption)
625
                        .foregroundColor(.secondary)
626
                }
627

            
628
                Spacer()
629

            
630
                Text("\(session.aggregatedSamples.count) points")
631
                    .font(.caption.weight(.semibold))
632
                    .foregroundColor(.secondary)
633
            }
634

            
635
            if let currentSeries {
636
                storedSeriesChart(
637
                    title: "Current",
638
                    unit: "A",
639
                    strokeColor: .blue,
640
                    snapshot: currentSeries
641
                )
642
            }
643

            
644
            if let energySeries {
645
                storedSeriesChart(
646
                    title: "Energy",
647
                    unit: "Wh",
648
                    strokeColor: .teal,
649
                    areaChart: true,
650
                    snapshot: energySeries
651
                )
652
            }
653

            
654
        }
655
        .frame(maxWidth: .infinity, alignment: .leading)
656
        .padding(18)
657
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
658
    }
659

            
660
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
661
        VStack(alignment: .leading, spacing: 12) {
662
            Text("Typical Charge Curve")
663
                .font(.headline)
664

            
665
            ForEach(chargedDevice.typicalCurve) { point in
666
                HStack {
667
                    Text("\(point.percentBin)%")
668
                        .font(.footnote.weight(.semibold))
669
                    Spacer()
670
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
671
                        .font(.caption.weight(.semibold))
672
                    Text("•")
673
                        .foregroundColor(.secondary)
674
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
675
                        .font(.caption2)
676
                        .foregroundColor(.secondary)
677
                }
678
            }
679
        }
680
        .frame(maxWidth: .infinity, alignment: .leading)
681
        .padding(18)
682
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
683
    }
684

            
685
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
686
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
687
            HStack(spacing: 8) {
688
                Text("Charge Sessions")
689
                    .font(.headline)
690
                ContextInfoButton(
691
                    title: "Charge Sessions",
692
                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
693
                )
694
            }
Bogdan Timofte authored a month ago
695

            
696
            ForEach(chargedDevice.sessions, id: \.id) { session in
697
                VStack(alignment: .leading, spacing: 6) {
698
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
699
                        Text(session.startedAt.format())
700
                            .font(.caption.weight(.semibold))
701
                        Text(session.status.title)
702
                            .font(.caption2.weight(.semibold))
703
                            .padding(.horizontal, 8)
704
                            .padding(.vertical, 4)
705
                            .background(
706
                                Capsule()
707
                                    .fill(statusTint(for: session).opacity(0.16))
708
                            )
709
                        Spacer()
710
                        Button {
711
                            pendingSessionDeletion = session
712
                        } label: {
713
                            Image(systemName: "trash")
714
                                .font(.caption.weight(.semibold))
715
                                .foregroundColor(.red)
716
                                .padding(8)
717
                                .background(
718
                                    Circle()
719
                                        .fill(Color.red.opacity(0.10))
720
                                )
721
                        }
722
                        .buttonStyle(.plain)
723
                    }
724

            
725
                    Text(sessionSummaryLine(session))
726
                        .font(.caption2)
727
                        .foregroundColor(.secondary)
728

            
729
                    MeterInfoRowView(
730
                        label: "Duration",
731
                        value: sessionDurationText(session)
732
                    )
733
                    MeterInfoRowView(
734
                        label: "Energy",
735
                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
736
                    )
737
                    if session.chargingTransportMode == .wireless,
738
                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
739
                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
740
                        MeterInfoRowView(
741
                            label: "Charger Energy",
742
                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
743
                        )
744
                    }
745
                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
746
                        MeterInfoRowView(
747
                            label: "Max Current",
748
                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
749
                        )
750
                    }
751
                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
752
                        MeterInfoRowView(
753
                            label: "Max Power",
754
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
755
                        )
756
                    }
757
                    if session.chargingTransportMode == .wired,
758
                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
759
                        MeterInfoRowView(
760
                            label: "Max Voltage",
761
                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
762
                        )
763
                    }
764
                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
765
                        MeterInfoRowView(
766
                            label: "Selected Voltage",
767
                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
768
                        )
769
                    }
770
                    if chargedDevice.isCharger == false,
771
                       let chargerID = session.chargerID,
772
                       let charger = appData.chargedDeviceSummary(id: chargerID) {
773
                        MeterInfoRowView(
774
                            label: "Wireless Charger",
775
                            value: charger.name
776
                        )
777
                    }
778
                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
779
                        Text(wirelessSessionHint)
780
                            .font(.caption2)
781
                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
782
                    }
783
                }
784
                .padding(14)
785
                .meterCard(
786
                    tint: statusTint(for: session),
787
                    fillOpacity: 0.10,
788
                    strokeOpacity: 0.16,
789
                    cornerRadius: 16
790
                )
791
            }
792
        }
793
        .frame(maxWidth: .infinity, alignment: .leading)
794
        .padding(18)
795
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
796
    }
797

            
798
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
799
        var components: [String] = []
800

            
801
        if let batteryDeltaPercent = session.batteryDeltaPercent {
802
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
803
        }
804

            
805
        if let capacityEstimateWh = session.capacityEstimateWh {
806
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
807
        }
808

            
809
        components.append(session.chargingTransportMode.title)
Bogdan Timofte authored a month ago
810
        components.append(session.chargingStateMode.title)
Bogdan Timofte authored a month ago
811
        components.append(session.sourceMode.title)
812
        return components.joined(separator: " • ")
813
    }
814

            
815
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
816
        guard session.chargingTransportMode == .wireless else {
817
            return nil
818
        }
819

            
820
        var components: [String] = []
821
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
822
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
823
        }
824
        if session.usesEstimatedWirelessEfficiency {
825
            components.append("Estimated from wired baseline and checkpoints")
826
        }
827
        if session.shouldWarnAboutLowWirelessEfficiency {
828
            components.append("Low wireless efficiency, so capacity confidence is reduced")
829
        }
830

            
831
        return components.isEmpty ? nil : components.joined(separator: " • ")
832
    }
833

            
834
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
835
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
836
        let effectiveDuration = max(session.effectiveDuration, 0)
837
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
838
        formatter.unitsStyle = .abbreviated
839
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
840
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
841
    }
842

            
843
    private func statusTint(for session: ChargeSessionSummary) -> Color {
844
        switch session.status {
845
        case .active:
846
            return .green
Bogdan Timofte authored a month ago
847
        case .paused:
848
            return .orange
Bogdan Timofte authored a month ago
849
        case .completed:
850
            return .teal
851
        case .abandoned:
Bogdan Timofte authored a month ago
852
            return .secondary
Bogdan Timofte authored a month ago
853
        }
854
    }
855

            
856
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
857
        switch chargedDevice.deviceClass {
858
        case .iphone:
859
            return .blue
860
        case .watch:
861
            return .green
862
        case .powerbank:
863
            return .orange
864
        case .charger:
865
            return .pink
866
        case .other:
867
            return .secondary
868
        }
869
    }
870

            
Bogdan Timofte authored a month ago
871
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
872
        if wattHours >= 1000 {
873
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
874
        }
875
        return "\(wattHours.format(decimalDigits: 2)) Wh"
876
    }
877

            
878
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
879
        appData.meterSummaries.filter { $0.meter != nil }
880
    }
881

            
Bogdan Timofte authored a month ago
882
    private func completionCurrentDescription(
883
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
884
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
885
    ) -> String {
Bogdan Timofte authored a month ago
886
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
887
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
888
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
889
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
890
            }
891
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
892
        }
893

            
Bogdan Timofte authored a month ago
894
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
895
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
896
        }
897

            
898
        return "Learning"
899
    }
900

            
Bogdan Timofte authored a month ago
901
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
902
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
903
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
904
                ChargeSessionKind(
905
                    chargingTransportMode: chargingTransportMode,
906
                    chargingStateMode: chargingStateMode
907
                )
908
            }
909
        }
910
    }
911

            
912
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
913
        if session.autoStopEnabled == false {
914
            return "Manual"
915
        }
916

            
917
        if let sessionWarning = sessionWarning(for: session),
918
           sessionWarning.contains("idle-current") {
919
            return "Blocked by charger setup"
920
        }
921

            
922
        if session.stopThresholdAmps > 0 {
923
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
924
        }
925

            
926
        return "Learning"
927
    }
928

            
929
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
930
        guard session.chargingTransportMode == .wireless,
931
              let chargerID = session.chargerID,
932
              let charger = appData.chargedDeviceSummary(id: chargerID),
933
              charger.chargerIdleCurrentAmps == nil else {
934
            return nil
935
        }
936

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

            
Bogdan Timofte authored a month ago
940
    private var deletionTitle: String {
941
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
942
    }
943

            
944
    private var deletionMessage: String {
945
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
946
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
947
        }
948
        return "This removes the device and its stored charging history from the library."
949
    }
950

            
951
    private func storedSeriesSnapshot(
952
        from samples: [ChargeSessionSampleSummary],
953
        minimumYSpan: Double,
954
        value: (ChargeSessionSampleSummary) -> Double
955
    ) -> StoredSeriesSnapshot? {
956
        let sortedSamples = samples.sorted { lhs, rhs in
957
            if lhs.bucketIndex != rhs.bucketIndex {
958
                return lhs.bucketIndex < rhs.bucketIndex
959
            }
960
            return lhs.timestamp < rhs.timestamp
961
        }
962

            
963
        guard
964
            let firstSample = sortedSamples.first,
965
            let lastSample = sortedSamples.last
966
        else {
967
            return nil
968
        }
969

            
970
        let points = sortedSamples.enumerated().map { index, sample in
971
            Measurements.Measurement.Point(
972
                id: index,
973
                timestamp: sample.timestamp,
974
                value: value(sample),
975
                kind: .sample
976
            )
977
        }
978

            
979
        let minimumValue = points.map(\.value).min() ?? 0
980
        let maximumValue = points.map(\.value).max() ?? minimumValue
981
        let context = ChartContext()
982
        context.setBounds(
983
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
984
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
985
            yMin: CGFloat(minimumValue),
986
            yMax: CGFloat(maximumValue)
987
        )
988
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
989

            
990
        return StoredSeriesSnapshot(
991
            points: points,
992
            context: context,
993
            minimumValue: minimumValue,
994
            maximumValue: maximumValue
995
        )
996
    }
997

            
998
    private func storedSeriesChart(
999
        title: String,
1000
        unit: String,
1001
        strokeColor: Color,
1002
        areaChart: Bool = false,
1003
        snapshot: StoredSeriesSnapshot
1004
    ) -> some View {
1005
        VStack(alignment: .leading, spacing: 8) {
1006
            HStack(alignment: .firstTextBaseline) {
1007
                Text(title)
1008
                    .font(.subheadline.weight(.semibold))
1009
                Spacer()
1010
                Text(
1011
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
1012
                )
1013
                .font(.caption2)
1014
                .foregroundColor(.secondary)
1015
            }
1016

            
1017
            Chart(
1018
                points: snapshot.points,
1019
                context: snapshot.context,
1020
                areaChart: areaChart,
1021
                strokeColor: strokeColor
1022
            )
1023
            .frame(height: 118)
1024
            .padding(.horizontal, 6)
1025
            .padding(.vertical, 8)
1026
            .background(
1027
                RoundedRectangle(cornerRadius: 16)
1028
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
1029
            )
1030

            
1031
            HStack {
1032
                Text(snapshot.startLabel)
1033
                Spacer()
1034
                Text(snapshot.endLabel)
1035
            }
1036
            .font(.caption2)
1037
            .foregroundColor(.secondary)
1038
        }
1039
    }
1040
}
1041

            
1042
private struct StoredSeriesSnapshot {
1043
    let points: [Measurements.Measurement.Point]
1044
    let context: ChartContext
1045
    let minimumValue: Double
1046
    let maximumValue: Double
1047

            
1048
    var lastValue: Double {
1049
        points.last?.value ?? 0
1050
    }
1051

            
1052
    var startLabel: String {
1053
        guard let firstTimestamp = points.first?.timestamp else { return "" }
1054
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
1055
    }
1056

            
1057
    var endLabel: String {
1058
        guard let lastTimestamp = points.last?.timestamp else { return "" }
1059
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
1060
    }
1061
}
1062

            
1063
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1064
    @Environment(\.dismiss) private var dismiss
1065
    @EnvironmentObject private var appData: AppData
1066

            
1067
    let sessionID: UUID
1068
    let initialTargetPercent: Double?
1069

            
1070
    @State private var targetPercent: Double
1071

            
1072
    init(sessionID: UUID, initialTargetPercent: Double?) {
1073
        self.sessionID = sessionID
1074
        self.initialTargetPercent = initialTargetPercent
1075
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1076
    }
1077

            
1078
    var body: some View {
1079
        NavigationView {
1080
            Form {
Bogdan Timofte authored a month ago
1081
                Section(
1082
                    header: ContextInfoHeader(
1083
                        title: "Target Level",
1084
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
1085
                    )
1086
                ) {
Bogdan Timofte authored a month ago
1087
                    VStack(alignment: .leading, spacing: 12) {
1088
                        Text("\(targetPercent.format(decimalDigits: 0))%")
1089
                            .font(.title3.weight(.bold))
1090
                        Slider(value: $targetPercent, in: 20...100, step: 1)
1091
                    }
1092
                }
1093
            }
1094
            .navigationTitle("Battery Target")
1095
            .navigationBarTitleDisplayMode(.inline)
1096
            .toolbar {
1097
                ToolbarItem(placement: .cancellationAction) {
1098
                    Button("Cancel") {
1099
                        dismiss()
1100
                    }
1101
                }
1102

            
1103
                ToolbarItem(placement: .confirmationAction) {
1104
                    Button("Save") {
1105
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
1106
                            dismiss()
1107
                        }
1108
                    }
1109
                }
1110
            }
1111
        }
1112
        .navigationViewStyle(StackNavigationViewStyle())
1113
    }
1114
}
Bogdan Timofte authored a month ago
1115

            
1116
private struct DeviceSessionStopRequest: Identifiable {
1117
    let sessionID: UUID
1118
    let title: String
1119
    let confirmTitle: String
1120
    let explanation: String
1121

            
1122
    var id: UUID {
1123
        sessionID
1124
    }
1125
}