USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceDetailView.swift
Newer Older
1131 lines | 47.672kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargedDeviceDetailView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
struct ChargedDeviceDetailView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @Environment(\.dismiss) private var dismiss
13
    @State private var editorVisibility = false
14
    @State private var targetNotificationEditorVisibility = false
15
    @State private var pendingSessionDeletion: ChargeSessionSummary?
Bogdan Timofte authored a month ago
16
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
Bogdan Timofte authored a month ago
17
    @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
Bogdan Timofte authored a month ago
18
    @State private var deleteConfirmationVisibility = false
19

            
20
    let chargedDeviceID: UUID
21

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

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

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

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

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

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

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

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

            
156
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
157
                ChargedDeviceIdentityLabelView(
158
                    chargedDevice: chargedDevice,
159
                    iconPointSize: 22
160
                )
161
                .font(.title3.weight(.bold))
Bogdan Timofte authored a month ago
162

            
Bogdan Timofte authored a month ago
163
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
164
                    .font(.subheadline.weight(.semibold))
165
                    .foregroundColor(.secondary)
166

            
167
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
168
                    Text("Default meter: \(meterMAC)")
169
                        .font(.caption)
170
                        .foregroundColor(.secondary)
171
                }
172

            
173
                Text(chargedDevice.qrIdentifier)
174
                    .font(.caption2.monospaced())
175
                    .foregroundColor(.secondary)
176
                    .textSelection(.enabled)
177
            }
178

            
179
            Spacer(minLength: 0)
180
        }
181
        .frame(maxWidth: .infinity, alignment: .leading)
182
        .padding(18)
183
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
184
    }
185

            
186
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
187
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
188
            if chargedDevice.isCharger {
189
                chargerInsights(chargedDevice)
190
            } else {
191
                deviceInsights(chargedDevice)
192
            }
193

            
194
            if let notes = chargedDevice.notes, !notes.isEmpty {
195
                Divider()
196
                Text(notes)
197
                    .font(.footnote)
198
                    .foregroundColor(.secondary)
199
                    .frame(maxWidth: .infinity, alignment: .leading)
200
            }
201
        }
202
    }
203

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

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

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

            
Bogdan Timofte authored a month ago
313
        if chargedDevice.chargerIdleCurrentAmps == nil {
314
            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.")
315
                .font(.caption2)
316
                .foregroundColor(.orange)
Bogdan Timofte authored a month ago
317
        }
318
    }
319

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

            
323
        return MeterInfoCardView(
324
            title: "Standby Power",
325
            tint: .orange
326
        ) {
327
            if standbyMeasurementMeters.isEmpty {
328
                Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
329
                    .font(.footnote)
330
                    .foregroundColor(.secondary)
331
                    .frame(maxWidth: .infinity, alignment: .leading)
332
            } else {
333
                NavigationLink(
334
                    destination: ChargerStandbyPowerWizardView(
335
                        preferredChargerID: chargedDevice.id,
336
                        locksChargerSelection: true
337
                    )
338
                ) {
339
                    Label("New Measurement", systemImage: "plus.circle.fill")
340
                        .font(.subheadline.weight(.semibold))
341
                        .foregroundColor(.orange)
342
                }
343
                .buttonStyle(.plain)
344
            }
345

            
346
            if let latestMeasurement {
347
                Divider()
348

            
349
                NavigationLink(
350
                    destination: ChargerStandbyPowerMeasurementDetailView(
351
                        chargerID: chargedDevice.id,
352
                        measurementID: latestMeasurement.id
353
                    )
354
                ) {
355
                    VStack(alignment: .leading, spacing: 8) {
356
                        HStack {
357
                            Text("Latest Measurement")
358
                                .font(.subheadline.weight(.semibold))
359
                                .foregroundColor(.primary)
360
                            Spacer()
361
                            Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
362
                                .font(.subheadline.weight(.bold))
363
                                .foregroundColor(.primary)
364
                                .monospacedDigit()
365
                        }
366

            
367
                        Text(
368
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
369
                        )
370
                        .font(.caption)
371
                        .foregroundColor(.secondary)
372
                    }
373
                }
374
                .buttonStyle(.plain)
375
            }
376

            
377
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
378
                Divider()
379

            
380
                NavigationLink(
381
                    destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
382
                ) {
383
                    Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
384
                        .font(.subheadline.weight(.semibold))
385
                        .foregroundColor(.blue)
386
                }
387
                .buttonStyle(.plain)
388
            }
389
        }
390
    }
391

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

            
Bogdan Timofte authored a month ago
455
            BatteryCheckpointSectionView(
456
                sessionID: activeSession.id,
457
                checkpoints: activeSession.checkpoints,
458
                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
459
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
460
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
461
                effectiveEnergyWhOverride: nil,
462
                measuredChargeAhOverride: nil,
463
                onDelete: { checkpoint in
464
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
465
                }
Bogdan Timofte authored a month ago
466
            )
Bogdan Timofte authored a month ago
467

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

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

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

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

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

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

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

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

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

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

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

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

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

            
607
                Spacer()
608

            
Bogdan Timofte authored a month ago
609
                Text("\(displayedSamples.count) points")
Bogdan Timofte authored a month ago
610
                    .font(.caption.weight(.semibold))
611
                    .foregroundColor(.secondary)
612
            }
613

            
614
            if let currentSeries {
615
                storedSeriesChart(
616
                    title: "Current",
617
                    unit: "A",
618
                    strokeColor: .blue,
619
                    snapshot: currentSeries
620
                )
621
            }
622

            
623
            if let energySeries {
624
                storedSeriesChart(
625
                    title: "Energy",
626
                    unit: "Wh",
627
                    strokeColor: .teal,
628
                    areaChart: true,
629
                    snapshot: energySeries
630
                )
631
            }
632

            
633
        }
634
        .frame(maxWidth: .infinity, alignment: .leading)
635
        .padding(18)
636
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
637
    }
638

            
639
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
640
        VStack(alignment: .leading, spacing: 12) {
641
            Text("Typical Charge Curve")
642
                .font(.headline)
643

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

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

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

            
704
                    Text(sessionSummaryLine(session))
705
                        .font(.caption2)
706
                        .foregroundColor(.secondary)
707

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

            
777
    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
778
        var components: [String] = []
Bogdan Timofte authored a month ago
779
        let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID)
Bogdan Timofte authored a month ago
780

            
781
        if let batteryDeltaPercent = session.batteryDeltaPercent {
782
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
783
        }
784

            
785
        if let capacityEstimateWh = session.capacityEstimateWh {
786
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
787
        }
788

            
Bogdan Timofte authored a month ago
789
        if chargedDevice?.shouldShowChargingTransport(session.chargingTransportMode) != false {
790
            components.append(session.chargingTransportMode.title)
791
        }
792
        if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
793
            components.append(session.chargingStateMode.title)
794
        }
Bogdan Timofte authored a month ago
795
        if session.isTrimmed {
796
            components.append("Trimmed")
797
        }
Bogdan Timofte authored a month ago
798
        components.append(session.sourceMode.title)
799
        return components.joined(separator: " • ")
800
    }
801

            
802
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
803
        guard session.chargingTransportMode == .wireless else {
804
            return nil
805
        }
806

            
807
        var components: [String] = []
808
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
809
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
810
        }
811
        if session.usesEstimatedWirelessEfficiency {
812
            components.append("Estimated from wired baseline and checkpoints")
813
        }
814
        if session.shouldWarnAboutLowWirelessEfficiency {
815
            components.append("Low wireless efficiency, so capacity confidence is reduced")
816
        }
817

            
818
        return components.isEmpty ? nil : components.joined(separator: " • ")
819
    }
820

            
821
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
822
        let formatter = DateComponentsFormatter()
Bogdan Timofte authored a month ago
823
        let effectiveDuration = max(session.effectiveDuration, 0)
824
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
Bogdan Timofte authored a month ago
825
        formatter.unitsStyle = .abbreviated
826
        formatter.zeroFormattingBehavior = .dropAll
Bogdan Timofte authored a month ago
827
        return formatter.string(from: effectiveDuration) ?? "0m"
Bogdan Timofte authored a month ago
828
    }
829

            
830
    private func statusTint(for session: ChargeSessionSummary) -> Color {
831
        switch session.status {
832
        case .active:
833
            return .green
Bogdan Timofte authored a month ago
834
        case .paused:
835
            return .orange
Bogdan Timofte authored a month ago
836
        case .completed:
837
            return .teal
838
        case .abandoned:
Bogdan Timofte authored a month ago
839
            return .secondary
Bogdan Timofte authored a month ago
840
        }
841
    }
842

            
843
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
844
        switch chargedDevice.deviceClass {
845
        case .iphone:
846
            return .blue
847
        case .watch:
848
            return .green
849
        case .powerbank:
850
            return .orange
851
        case .charger:
852
            return .pink
853
        case .other:
854
            return .secondary
855
        }
856
    }
857

            
Bogdan Timofte authored a month ago
858
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
859
        if wattHours >= 1000 {
860
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
861
        }
862
        return "\(wattHours.format(decimalDigits: 2)) Wh"
863
    }
864

            
865
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
866
        appData.meterSummaries.filter { $0.meter != nil }
867
    }
868

            
Bogdan Timofte authored a month ago
869
    private func completionCurrentDescription(
870
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
871
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
872
    ) -> String {
Bogdan Timofte authored a month ago
873
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
874
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
875
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
876
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
877
            }
878
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
879
        }
880

            
Bogdan Timofte authored a month ago
881
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
882
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
883
        }
884

            
885
        return "Learning"
886
    }
887

            
Bogdan Timofte authored a month ago
888
    private func completionCurrentLabel(
889
        for chargedDevice: ChargedDeviceSummary,
890
        sessionKind: ChargeSessionKind
891
    ) -> String {
892
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
893
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
894

            
895
        switch (showsTransport, showsState) {
896
        case (true, true):
897
            return "\(sessionKind.shortTitle) Stop Current"
898
        case (true, false):
899
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
900
        case (false, true):
901
            return "\(sessionKind.chargingStateMode.title) Stop Current"
902
        case (false, false):
903
            return "Stop Current"
904
        }
905
    }
906

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

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

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

            
928
        if session.stopThresholdAmps > 0 {
929
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
930
        }
931

            
932
        return "Learning"
933
    }
934

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

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

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

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

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

            
969
        guard
970
            let firstSample = sortedSamples.first,
971
            let lastSample = sortedSamples.last
972
        else {
973
            return nil
974
        }
975

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

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

            
996
        return StoredSeriesSnapshot(
997
            points: points,
998
            context: context,
999
            minimumValue: minimumValue,
1000
            maximumValue: maximumValue
1001
        )
1002
    }
1003

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

            
Bogdan Timofte authored a month ago
1023
            TimeSeriesChart(
Bogdan Timofte authored a month ago
1024
                points: snapshot.points,
1025
                context: snapshot.context,
1026
                areaChart: areaChart,
1027
                strokeColor: strokeColor
1028
            )
1029
            .frame(height: 118)
1030
            .padding(.horizontal, 6)
1031
            .padding(.vertical, 8)
1032
            .background(
1033
                RoundedRectangle(cornerRadius: 16)
1034
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
1035
            )
1036

            
1037
            HStack {
1038
                Text(snapshot.startLabel)
1039
                Spacer()
1040
                Text(snapshot.endLabel)
1041
            }
1042
            .font(.caption2)
1043
            .foregroundColor(.secondary)
1044
        }
1045
    }
1046
}
1047

            
1048
private struct StoredSeriesSnapshot {
1049
    let points: [Measurements.Measurement.Point]
1050
    let context: ChartContext
1051
    let minimumValue: Double
1052
    let maximumValue: Double
1053

            
1054
    var lastValue: Double {
1055
        points.last?.value ?? 0
1056
    }
1057

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

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

            
1069
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
1070
    @Environment(\.dismiss) private var dismiss
1071
    @EnvironmentObject private var appData: AppData
1072

            
1073
    let sessionID: UUID
1074
    let initialTargetPercent: Double?
1075

            
1076
    @State private var targetPercent: Double
1077

            
1078
    init(sessionID: UUID, initialTargetPercent: Double?) {
1079
        self.sessionID = sessionID
1080
        self.initialTargetPercent = initialTargetPercent
1081
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
1082
    }
1083

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

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

            
1122
private struct DeviceSessionStopRequest: Identifiable {
1123
    let sessionID: UUID
1124
    let title: String
1125
    let confirmTitle: String
1126
    let explanation: String
1127

            
1128
    var id: UUID {
1129
        sessionID
1130
    }
1131
}