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

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

            
Bogdan Timofte authored a month ago
454
                if showsInlineCheckpointEditor {
455
                    BatteryCheckpointEditorContentView(
456
                        sessionID: activeSession.id,
457
                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
458
                        effectiveEnergyWhOverride: nil,
459
                        measuredChargeAhOverride: nil,
460
                        onCancel: { showsInlineCheckpointEditor = false },
461
                        onSaved: { showsInlineCheckpointEditor = false }
462
                    )
463
                }
464
            } else if let checkpointEditingMessage = appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id) {
465
                Text(checkpointEditingMessage)
466
                    .font(.caption2)
467
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
468
            }
469

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
568
    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
569
        VStack(alignment: .leading, spacing: 8) {
570
            Text("Battery Checkpoints")
571
                .font(.subheadline.weight(.semibold))
572

            
573
            ForEach(checkpoints, id: \.id) { checkpoint in
574
                HStack {
575
                    Text(checkpoint.timestamp.format())
576
                        .font(.caption2)
577
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
578
                    Text(checkpoint.flag.title)
579
                        .font(.caption2.weight(.semibold))
580
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
581
                    Spacer()
582
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
583
                        .font(.caption.weight(.semibold))
584
                    Text("•")
585
                        .foregroundColor(.secondary)
586
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
587
                        .font(.caption2)
588
                        .foregroundColor(.secondary)
589
                    Button {
590
                        pendingCheckpointDeletion = checkpoint
591
                    } label: {
592
                        Image(systemName: "trash")
593
                            .font(.caption.weight(.semibold))
594
                            .foregroundColor(.red)
595
                    }
596
                    .buttonStyle(.plain)
597
                    .help("Delete checkpoint")
598
                }
599
            }
600
        }
601
    }
602

            
Bogdan Timofte authored a month ago
603
    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
604
        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
605
            return activeSession
606
        }
607

            
608
        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
609
    }
610

            
611
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
612
        let currentSeries = storedSeriesSnapshot(
613
            from: session.aggregatedSamples,
614
            minimumYSpan: 0.15
615
        ) { $0.averageCurrentAmps }
616
        let energySeries = storedSeriesSnapshot(
617
            from: session.aggregatedSamples,
618
            minimumYSpan: 0.2
619
        ) { $0.measuredEnergyWh }
620

            
621
        return VStack(alignment: .leading, spacing: 14) {
622
            HStack(alignment: .firstTextBaseline) {
623
                VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
624
                    HStack(spacing: 8) {
625
                        Text("Stored Session Curve")
626
                            .font(.headline)
627
                        ContextInfoButton(
628
                            title: "Stored Session Curve",
629
                            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."
630
                        )
631
                    }
Bogdan Timofte authored a month ago
632
                    Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
Bogdan Timofte authored a month ago
633
                        .font(.caption)
634
                        .foregroundColor(.secondary)
635
                }
636

            
637
                Spacer()
638

            
639
                Text("\(session.aggregatedSamples.count) points")
640
                    .font(.caption.weight(.semibold))
641
                    .foregroundColor(.secondary)
642
            }
643

            
644
            if let currentSeries {
645
                storedSeriesChart(
646
                    title: "Current",
647
                    unit: "A",
648
                    strokeColor: .blue,
649
                    snapshot: currentSeries
650
                )
651
            }
652

            
653
            if let energySeries {
654
                storedSeriesChart(
655
                    title: "Energy",
656
                    unit: "Wh",
657
                    strokeColor: .teal,
658
                    areaChart: true,
659
                    snapshot: energySeries
660
                )
661
            }
662

            
663
        }
664
        .frame(maxWidth: .infinity, alignment: .leading)
665
        .padding(18)
666
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
667
    }
668

            
669
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
670
        VStack(alignment: .leading, spacing: 12) {
671
            Text("Typical Charge Curve")
672
                .font(.headline)
673

            
674
            ForEach(chargedDevice.typicalCurve) { point in
675
                HStack {
676
                    Text("\(point.percentBin)%")
677
                        .font(.footnote.weight(.semibold))
678
                    Spacer()
679
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
680
                        .font(.caption.weight(.semibold))
681
                    Text("•")
682
                        .foregroundColor(.secondary)
683
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
684
                        .font(.caption2)
685
                        .foregroundColor(.secondary)
686
                }
687
            }
688
        }
689
        .frame(maxWidth: .infinity, alignment: .leading)
690
        .padding(18)
691
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
692
    }
693

            
694
    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
695
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
696
            HStack(spacing: 8) {
697
                Text("Charge Sessions")
698
                    .font(.headline)
699
                ContextInfoButton(
700
                    title: "Charge Sessions",
701
                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
702
                )
703
            }
Bogdan Timofte authored a month ago
704

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

            
734
                    Text(sessionSummaryLine(session))
735
                        .font(.caption2)
736
                        .foregroundColor(.secondary)
737

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

            
807
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
808
        var components: [String] = []
809

            
810
        if let batteryDeltaPercent = session.batteryDeltaPercent {
811
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
812
        }
813

            
814
        if let capacityEstimateWh = session.capacityEstimateWh {
815
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
816
        }
817

            
818
        components.append(session.chargingTransportMode.title)
Bogdan Timofte authored a month ago
819
        components.append(session.chargingStateMode.title)
Bogdan Timofte authored a month ago
820
        components.append(session.sourceMode.title)
821
        return components.joined(separator: " • ")
822
    }
823

            
824
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
825
        guard session.chargingTransportMode == .wireless else {
826
            return nil
827
        }
828

            
829
        var components: [String] = []
830
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
831
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
832
        }
833
        if session.usesEstimatedWirelessEfficiency {
834
            components.append("Estimated from wired baseline and checkpoints")
835
        }
836
        if session.shouldWarnAboutLowWirelessEfficiency {
837
            components.append("Low wireless efficiency, so capacity confidence is reduced")
838
        }
839

            
840
        return components.isEmpty ? nil : components.joined(separator: " • ")
841
    }
842

            
843
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
844
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
845
        let effectiveDuration = max(session.effectiveDuration, 0)
846
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
847
        formatter.unitsStyle = .abbreviated
848
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
849
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
850
    }
851

            
852
    private func statusTint(for session: ChargeSessionSummary) -> Color {
853
        switch session.status {
854
        case .active:
855
            return .green
Bogdan Timofte authored a month ago
856
        case .paused:
857
            return .orange
Bogdan Timofte authored a month ago
858
        case .completed:
859
            return .teal
860
        case .abandoned:
Bogdan Timofte authored a month ago
861
            return .secondary
Bogdan Timofte authored a month ago
862
        }
863
    }
864

            
865
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
866
        switch chargedDevice.deviceClass {
867
        case .iphone:
868
            return .blue
869
        case .watch:
870
            return .green
871
        case .powerbank:
872
            return .orange
873
        case .charger:
874
            return .pink
875
        case .other:
876
            return .secondary
877
        }
878
    }
879

            
Bogdan Timofte authored a month ago
880
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
881
        if wattHours >= 1000 {
882
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
883
        }
884
        return "\(wattHours.format(decimalDigits: 2)) Wh"
885
    }
886

            
887
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
888
        appData.meterSummaries.filter { $0.meter != nil }
889
    }
890

            
Bogdan Timofte authored a month ago
891
    private func completionCurrentDescription(
892
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
893
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
894
    ) -> String {
Bogdan Timofte authored a month ago
895
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
896
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
897
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
898
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
899
            }
900
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
901
        }
902

            
Bogdan Timofte authored a month ago
903
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
904
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
905
        }
906

            
907
        return "Learning"
908
    }
909

            
Bogdan Timofte authored a month ago
910
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
911
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
912
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
913
                ChargeSessionKind(
914
                    chargingTransportMode: chargingTransportMode,
915
                    chargingStateMode: chargingStateMode
916
                )
917
            }
918
        }
919
    }
920

            
921
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
922
        if session.autoStopEnabled == false {
923
            return "Manual"
924
        }
925

            
926
        if let sessionWarning = sessionWarning(for: session),
927
           sessionWarning.contains("idle-current") {
928
            return "Blocked by charger setup"
929
        }
930

            
931
        if session.stopThresholdAmps > 0 {
932
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
933
        }
934

            
935
        return "Learning"
936
    }
937

            
938
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
939
        guard session.chargingTransportMode == .wireless,
940
              let chargerID = session.chargerID,
941
              let charger = appData.chargedDeviceSummary(id: chargerID),
942
              charger.chargerIdleCurrentAmps == nil else {
943
            return nil
944
        }
945

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

            
Bogdan Timofte authored a month ago
949
    private var deletionTitle: String {
950
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
951
    }
952

            
953
    private var deletionMessage: String {
954
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
955
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
956
        }
957
        return "This removes the device and its stored charging history from the library."
958
    }
959

            
960
    private func storedSeriesSnapshot(
961
        from samples: [ChargeSessionSampleSummary],
962
        minimumYSpan: Double,
963
        value: (ChargeSessionSampleSummary) -> Double
964
    ) -> StoredSeriesSnapshot? {
965
        let sortedSamples = samples.sorted { lhs, rhs in
966
            if lhs.bucketIndex != rhs.bucketIndex {
967
                return lhs.bucketIndex < rhs.bucketIndex
968
            }
969
            return lhs.timestamp < rhs.timestamp
970
        }
971

            
972
        guard
973
            let firstSample = sortedSamples.first,
974
            let lastSample = sortedSamples.last
975
        else {
976
            return nil
977
        }
978

            
979
        let points = sortedSamples.enumerated().map { index, sample in
980
            Measurements.Measurement.Point(
981
                id: index,
982
                timestamp: sample.timestamp,
983
                value: value(sample),
984
                kind: .sample
985
            )
986
        }
987

            
988
        let minimumValue = points.map(\.value).min() ?? 0
989
        let maximumValue = points.map(\.value).max() ?? minimumValue
990
        let context = ChartContext()
991
        context.setBounds(
992
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
993
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
994
            yMin: CGFloat(minimumValue),
995
            yMax: CGFloat(maximumValue)
996
        )
997
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
998

            
999
        return StoredSeriesSnapshot(
1000
            points: points,
1001
            context: context,
1002
            minimumValue: minimumValue,
1003
            maximumValue: maximumValue
1004
        )
1005
    }
1006

            
1007
    private func storedSeriesChart(
1008
        title: String,
1009
        unit: String,
1010
        strokeColor: Color,
1011
        areaChart: Bool = false,
1012
        snapshot: StoredSeriesSnapshot
1013
    ) -> some View {
1014
        VStack(alignment: .leading, spacing: 8) {
1015
            HStack(alignment: .firstTextBaseline) {
1016
                Text(title)
1017
                    .font(.subheadline.weight(.semibold))
1018
                Spacer()
1019
                Text(
1020
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
1021
                )
1022
                .font(.caption2)
1023
                .foregroundColor(.secondary)
1024
            }
1025

            
1026
            Chart(
1027
                points: snapshot.points,
1028
                context: snapshot.context,
1029
                areaChart: areaChart,
1030
                strokeColor: strokeColor
1031
            )
1032
            .frame(height: 118)
1033
            .padding(.horizontal, 6)
1034
            .padding(.vertical, 8)
1035
            .background(
1036
                RoundedRectangle(cornerRadius: 16)
1037
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
1038
            )
1039

            
1040
            HStack {
1041
                Text(snapshot.startLabel)
1042
                Spacer()
1043
                Text(snapshot.endLabel)
1044
            }
1045
            .font(.caption2)
1046
            .foregroundColor(.secondary)
1047
        }
1048
    }
1049
}
1050

            
1051
private struct StoredSeriesSnapshot {
1052
    let points: [Measurements.Measurement.Point]
1053
    let context: ChartContext
1054
    let minimumValue: Double
1055
    let maximumValue: Double
1056

            
1057
    var lastValue: Double {
1058
        points.last?.value ?? 0
1059
    }
1060

            
1061
    var startLabel: String {
1062
        guard let firstTimestamp = points.first?.timestamp else { return "" }
1063
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
1064
    }
1065

            
1066
    var endLabel: String {
1067
        guard let lastTimestamp = points.last?.timestamp else { return "" }
1068
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
1069
    }
1070
}
1071

            
1072
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1073
    @Environment(\.dismiss) private var dismiss
1074
    @EnvironmentObject private var appData: AppData
1075

            
1076
    let sessionID: UUID
1077
    let initialTargetPercent: Double?
1078

            
1079
    @State private var targetPercent: Double
1080

            
1081
    init(sessionID: UUID, initialTargetPercent: Double?) {
1082
        self.sessionID = sessionID
1083
        self.initialTargetPercent = initialTargetPercent
1084
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1085
    }
1086

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

            
1112
                ToolbarItem(placement: .confirmationAction) {
1113
                    Button("Save") {
1114
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
1115
                            dismiss()
1116
                        }
1117
                    }
1118
                }
1119
            }
1120
        }
1121
        .navigationViewStyle(StackNavigationViewStyle())
1122
    }
1123
}
Bogdan Timofte authored a month ago
1124

            
1125
private struct DeviceSessionStopRequest: Identifiable {
1126
    let sessionID: UUID
1127
    let title: String
1128
    let confirmTitle: String
1129
    let explanation: String
1130

            
1131
    var id: UUID {
1132
        sessionID
1133
    }
1134
}