USB-Meter / USB Meter / Views / ChargedDevices / Details / ChargedDeviceDetailView.swift
Newer Older
1103 lines | 43.489kb
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 {
Bogdan Timofte authored a month ago
11
    private enum DetailTab: Hashable {
12
        case overview
13
        case standby
14
        case sessions
15
        case trends
16
        case settings
17
    }
18

            
Bogdan Timofte authored a month ago
19
    @EnvironmentObject private var appData: AppData
20
    @Environment(\.dismiss) private var dismiss
Bogdan Timofte authored a month ago
21

            
Bogdan Timofte authored a month ago
22
    @State private var editorVisibility = false
23
    @State private var deleteConfirmationVisibility = false
Bogdan Timofte authored a month ago
24
    @State private var selectedTab: DetailTab = .overview
Bogdan Timofte authored a month ago
25
    @State private var sessionSelectMode = false
26
    @State private var selectedSessionIDs: Set<UUID> = []
27
    @State private var pendingBatchDeletion = false
Bogdan Timofte authored a month ago
28

            
29
    let chargedDeviceID: UUID
30

            
31
    var body: some View {
32
        Group {
33
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
34
                tabbedDetailView(chargedDevice)
Bogdan Timofte authored a month ago
35
                .navigationTitle(chargedDevice.name)
36
            } else {
37
                Text("This device is no longer available.")
38
                    .foregroundColor(.secondary)
39
                    .navigationTitle("Device")
40
            }
41
        }
Bogdan Timofte authored a month ago
42
        .sidebarToggleToolbarItem()
Bogdan Timofte authored a month ago
43
        .sheet(isPresented: $editorVisibility) {
44
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
45
                if chargedDevice.isCharger {
Bogdan Timofte authored a month ago
46
                    ChargerEditorSheetView(chargedDevice: chargedDevice)
47
                        .environmentObject(appData)
Bogdan Timofte authored a month ago
48
                } else {
49
                    ChargedDeviceEditorSheetView(
50
                        meterMACAddress: nil,
51
                        chargedDevice: chargedDevice
52
                    )
53
                    .environmentObject(appData)
54
                }
Bogdan Timofte authored a month ago
55
            }
56
        }
57
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
58
            Button("Delete", role: .destructive) {
59
                if appData.deleteChargedDevice(id: chargedDeviceID) {
60
                    dismiss()
61
                }
62
            }
63
            Button("Cancel", role: .cancel) {}
64
        } message: {
65
            Text(deletionMessage)
66
        }
Bogdan Timofte authored a month ago
67
        .confirmationDialog(
68
            "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?",
69
            isPresented: $pendingBatchDeletion,
70
            titleVisibility: .visible
71
        ) {
72
            Button("Delete", role: .destructive, action: deleteSelectedSessions)
73
            Button("Cancel", role: .cancel) {}
74
        } message: {
75
            Text("Deleting these sessions also recalculates capacity and every derived metric that used them.")
76
        }
Bogdan Timofte authored a month ago
77
    }
78

            
Bogdan Timofte authored a month ago
79
    private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View {
80
        GeometryReader { proxy in
81
            let tabs = availableTabs(for: chargedDevice)
82
            let displayedTab = displayedTab(from: tabs)
83
            let tabBarPresentation = AdaptiveTabBarPresentation.standard(for: proxy.size)
84

            
85
            VStack(spacing: 0) {
86
                ChargedDeviceDetailTabBarView(
87
                    tabs: tabs,
88
                    selection: $selectedTab,
89
                    tint: tint(for: chargedDevice),
90
                    presentation: tabBarPresentation,
91
                    title: title(for:),
92
                    systemImage: systemImage(for:)
93
                )
94

            
Bogdan Timofte authored a month ago
95
                Group {
96
                    if displayedTab == .sessions {
97
                        sessionsTabLayout(chargedDevice)
98
                    } else {
99
                        ScrollView {
100
                            tabContent(displayedTab, chargedDevice: chargedDevice)
101
                                .padding()
102
                        }
103
                    }
Bogdan Timofte authored a month ago
104
                }
105
                .id(displayedTab)
106
                .transition(.opacity.combined(with: .move(edge: .trailing)))
107
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
108
            }
109
            .animation(.easeInOut(duration: 0.22), value: displayedTab)
110
            .animation(.easeInOut(duration: 0.22), value: tabs)
Bogdan Timofte authored a month ago
111
            .onChange(of: selectedTab) { _ in
112
                sessionSelectMode = false
113
                selectedSessionIDs.removeAll()
114
            }
Bogdan Timofte authored a month ago
115
        }
116
        .background(detailBackground(for: chargedDevice))
117
        .onAppear {
118
            ensureSelectedTabExists(for: chargedDevice)
119
        }
120
        .onChange(of: chargedDevice.isCharger) { _ in
121
            ensureSelectedTabExists(for: chargedDevice)
122
        }
123
    }
124

            
125
    @ViewBuilder
126
    private func tabContent(_ tab: DetailTab, chargedDevice: ChargedDeviceSummary) -> some View {
127
        VStack(spacing: 18) {
128
            switch tab {
129
            case .overview:
130
                overviewTab(chargedDevice)
131
            case .standby:
132
                standbyTab(chargedDevice)
133
            case .sessions:
134
                sessionsTab(chargedDevice)
135
            case .trends:
136
                trendsTab(chargedDevice)
137
            case .settings:
138
                settingsTab(chargedDevice)
139
            }
140
        }
141
    }
142

            
143
    @ViewBuilder
144
    private func overviewTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
145
        headerCard(chargedDevice)
146
        insightsCard(chargedDevice)
147

            
148
        if let activeSession = chargedDevice.activeSession {
149
            activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice)
150
        }
151
    }
152

            
153
    @ViewBuilder
154
    private func standbyTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
155
        standbyPowerCard(chargedDevice)
156
    }
157

            
158
    @ViewBuilder
159
    private func sessionsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
160
        if let activeSession = chargedDevice.activeSession {
161
            activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice)
162
        }
163

            
Bogdan Timofte authored a month ago
164
        let sessions = closedSessions(for: chargedDevice)
165
        if !sessions.isEmpty {
166
            sessionListCard(sessions, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
167
        } else if chargedDevice.activeSession == nil {
168
            emptyStateCard(
169
                title: "No Sessions",
Bogdan Timofte authored a month ago
170
                message: "Charging sessions will appear here after this device is used in a recording.",
Bogdan Timofte authored a month ago
171
                tint: .teal
172
            )
173
        }
174
    }
175

            
176
    @ViewBuilder
177
    private func trendsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
178
        if !chargedDevice.capacityHistory.isEmpty {
179
            capacityEvolutionCard(chargedDevice)
180
        }
181

            
182
        if !chargedDevice.typicalCurve.isEmpty {
183
            typicalCurveCard(chargedDevice)
184
        }
185

            
186
        if chargedDevice.capacityHistory.isEmpty && chargedDevice.typicalCurve.isEmpty {
187
            emptyStateCard(
188
                title: "Learning Trends",
189
                message: "Capacity history and charge curves will appear after enough completed sessions are available.",
190
                tint: .blue
191
            )
192
        }
193
    }
194

            
195
    @ViewBuilder
196
    private func settingsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
197
        settingsCard(chargedDevice)
198
    }
199

            
Bogdan Timofte authored a month ago
200
    @ViewBuilder
201
    private func sessionsTabLayout(_ chargedDevice: ChargedDeviceSummary) -> some View {
202
        let allSessions = chargedDevice.sessions.sorted { lhs, rhs in
203
            let lOpen = lhs.status.isOpen, rOpen = rhs.status.isOpen
204
            if lOpen != rOpen { return lOpen }
205
            return lhs.startedAt > rhs.startedAt
206
        }
207
        let totalEnergyWh = allSessions.reduce(0.0) { $0 + $1.effectiveOrMeasuredEnergyWh }
208
        let totalDuration  = allSessions.reduce(0.0) { $0 + max($1.effectiveDuration, 0) }
209

            
210
        VStack(spacing: 0) {
211
            // Fixed non-scrolling header
212
            VStack(spacing: 10) {
213
                sessionsSummaryStrip(
214
                    count: allSessions.count,
215
                    totalEnergyWh: totalEnergyWh,
216
                    totalDuration: totalDuration,
217
                    hasActive: chargedDevice.activeSession != nil
218
                )
219

            
220
                if !allSessions.isEmpty {
221
                    HStack(spacing: 12) {
222
                        if sessionSelectMode && !selectedSessionIDs.isEmpty {
223
                            Text("\(selectedSessionIDs.count) selected")
224
                                .font(.subheadline)
225
                                .foregroundColor(.secondary)
226
                                .transition(.opacity.combined(with: .move(edge: .leading)))
227
                        }
228
                        Spacer()
229
                        if sessionSelectMode && !selectedSessionIDs.isEmpty {
230
                            Button {
231
                                pendingBatchDeletion = true
232
                            } label: {
233
                                Image(systemName: "trash").foregroundColor(.red)
234
                            }
235
                            .transition(.opacity.combined(with: .scale))
236
                        }
237
                        Button(sessionSelectMode ? "Cancel" : "Select") {
238
                            withAnimation(.easeInOut(duration: 0.2)) {
239
                                sessionSelectMode.toggle()
240
                                if !sessionSelectMode { selectedSessionIDs.removeAll() }
241
                            }
242
                        }
243
                    }
244
                    .animation(.easeInOut(duration: 0.2), value: sessionSelectMode)
245
                    .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty)
246
                }
247
            }
248
            .padding()
249

            
250
            // Scrollable session list
251
            if allSessions.isEmpty {
252
                emptyStateCard(
253
                    title: "No Sessions",
254
                    message: "Charging sessions will appear here after this device is used in a recording.",
255
                    tint: .teal
256
                )
257
                .padding([.horizontal, .bottom])
258
            } else {
259
                ScrollView {
260
                    VStack(spacing: 10) {
261
                        ForEach(allSessions, id: \.id) { session in
262
                            sessionListItem(session, chargedDevice: chargedDevice)
263
                        }
264
                    }
265
                    .padding([.horizontal, .bottom])
266
                }
267
            }
268
        }
269
    }
270

            
271
    private func sessionsSummaryStrip(
272
        count: Int,
273
        totalEnergyWh: Double,
274
        totalDuration: TimeInterval,
275
        hasActive: Bool
276
    ) -> some View {
277
        HStack(spacing: 0) {
278
            summaryCell(value: "\(count)", label: count == 1 ? "session" : "sessions")
279
            Divider().frame(height: 30)
280
            summaryCell(value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh", label: "energy")
281
            Divider().frame(height: 30)
282
            summaryCell(value: formatAccumulatedDuration(totalDuration), label: "duration")
283
            if hasActive {
284
                Divider().frame(height: 30)
285
                HStack(spacing: 4) {
286
                    Circle().fill(Color.green).frame(width: 6, height: 6)
287
                    Text("Live")
288
                        .font(.caption2.weight(.semibold))
289
                        .foregroundColor(.green)
290
                }
291
                .frame(maxWidth: .infinity)
292
            }
293
        }
294
        .padding(.vertical, 8)
295
        .padding(.horizontal, 12)
296
        .meterCard(tint: .teal, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14)
297
    }
298

            
299
    private func summaryCell(value: String, label: String) -> some View {
300
        VStack(spacing: 2) {
301
            Text(value)
302
                .font(.subheadline.weight(.bold))
303
                .foregroundColor(.primary)
304
                .monospacedDigit()
305
                .lineLimit(1)
306
                .minimumScaleFactor(0.7)
307
            Text(label)
308
                .font(.caption2)
309
                .foregroundColor(.secondary)
310
        }
311
        .frame(maxWidth: .infinity)
312
    }
313

            
314
    private func formatAccumulatedDuration(_ duration: TimeInterval) -> String {
315
        let formatter = DateComponentsFormatter()
316
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
317
        formatter.unitsStyle = .abbreviated
318
        formatter.zeroFormattingBehavior = .dropAll
319
        return formatter.string(from: duration) ?? "0m"
320
    }
321

            
Bogdan Timofte authored a month ago
322
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
323
        HStack(alignment: .top, spacing: 18) {
324
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
325

            
326
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
327
                ChargedDeviceIdentityLabelView(
328
                    chargedDevice: chargedDevice,
329
                    iconPointSize: 22
330
                )
331
                .font(.title3.weight(.bold))
Bogdan Timofte authored a month ago
332

            
Bogdan Timofte authored a month ago
333
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
334
                    .font(.subheadline.weight(.semibold))
335
                    .foregroundColor(.secondary)
336

            
337
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
338
                    Text("Default meter: \(meterMAC)")
339
                        .font(.caption)
340
                        .foregroundColor(.secondary)
341
                }
342

            
343
                Text(chargedDevice.qrIdentifier)
344
                    .font(.caption2.monospaced())
345
                    .foregroundColor(.secondary)
346
                    .textSelection(.enabled)
347
            }
348

            
349
            Spacer(minLength: 0)
350
        }
351
        .frame(maxWidth: .infinity, alignment: .leading)
352
        .padding(18)
353
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
354
    }
355

            
Bogdan Timofte authored a month ago
356
    private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
357
        MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) {
358
            MeterInfoRowView(
359
                label: "Kind",
360
                value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title
361
            )
362
            MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle)
363
            MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier)
364

            
365
            if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
366
                MeterInfoRowView(label: "Default Meter", value: meterMAC)
367
            }
368

            
369
            MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format())
370
            MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format())
371

            
372
            Divider()
373

            
374
            Button(action: showEditor) {
375
                Label("Edit \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "pencil")
376
                    .font(.subheadline.weight(.semibold))
377
                    .frame(maxWidth: .infinity)
378
                    .padding(.vertical, 10)
379
                    .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
380
            }
381
            .buttonStyle(.plain)
382

            
383
            Button(role: .destructive, action: showDeleteConfirmation) {
384
                Label("Delete \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "trash")
385
                    .font(.subheadline.weight(.semibold))
386
                    .frame(maxWidth: .infinity)
387
                    .padding(.vertical, 10)
388
                    .meterCard(tint: .red, fillOpacity: 0.10, strokeOpacity: 0.18, cornerRadius: 14)
389
            }
390
            .buttonStyle(.plain)
391
        }
392
    }
393

            
394
    private func emptyStateCard(title: String, message: String, tint: Color) -> some View {
395
        VStack(alignment: .leading, spacing: 8) {
396
            Text(title)
397
                .font(.headline)
398
            Text(message)
399
                .font(.footnote)
400
                .foregroundColor(.secondary)
401
                .fixedSize(horizontal: false, vertical: true)
402
        }
403
        .frame(maxWidth: .infinity, alignment: .leading)
404
        .padding(18)
405
        .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
406
    }
407

            
Bogdan Timofte authored a month ago
408
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
409
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
410
            if chargedDevice.isCharger {
411
                chargerInsights(chargedDevice)
412
            } else {
413
                deviceInsights(chargedDevice)
414
            }
415

            
416
            if let notes = chargedDevice.notes, !notes.isEmpty {
417
                Divider()
418
                Text(notes)
419
                    .font(.footnote)
420
                    .foregroundColor(.secondary)
421
                    .frame(maxWidth: .infinity, alignment: .leading)
422
            }
423
        }
424
    }
425

            
426
    @ViewBuilder
427
    private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
428
        if chargedDevice.hasMultipleChargingStateModes {
429
            MeterInfoRowView(
430
                label: "Charge Modes",
431
                value: chargedDevice.chargingStateAvailability.title
432
            )
433
        }
434
        if chargedDevice.hasMultipleChargingTransports {
435
            MeterInfoRowView(
436
                label: "Charging Support",
437
                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
438
            )
439
        }
440
        if chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
441
            MeterInfoRowView(
Bogdan Timofte authored a month ago
442
                label: "Wireless Profile",
443
                value: chargedDevice.wirelessChargingProfile.title
Bogdan Timofte authored a month ago
444
            )
Bogdan Timofte authored a month ago
445
        }
446

            
447
        ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
Bogdan Timofte authored a month ago
448
            MeterInfoRowView(
Bogdan Timofte authored a month ago
449
                label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind),
Bogdan Timofte authored a month ago
450
                value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
Bogdan Timofte authored a month ago
451
            )
Bogdan Timofte authored a month ago
452
        }
453
        MeterInfoRowView(
454
            label: "Estimated Capacity",
455
            value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
456
        )
457
        if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
458
            if chargedDevice.hasMultipleChargingTransports {
459
                MeterInfoRowView(
460
                    label: "Wired Capacity",
461
                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
462
                )
463
            }
Bogdan Timofte authored a month ago
464
        }
465
        if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
466
            if chargedDevice.hasMultipleChargingTransports {
467
                MeterInfoRowView(
468
                    label: "Wireless Capacity",
469
                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
470
                )
471
            }
Bogdan Timofte authored a month ago
472
        }
Bogdan Timofte authored a month ago
473
        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor,
474
           chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
475
            MeterInfoRowView(
476
                label: "Wireless Efficiency",
477
                value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
478
            )
479
        }
480
        MeterInfoRowView(
481
            label: "Charge Sessions",
482
            value: "\(chargedDevice.sessionCount)"
483
        )
484
    }
Bogdan Timofte authored a month ago
485

            
Bogdan Timofte authored a month ago
486
    @ViewBuilder
487
    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
488
        if let chargerType = chargedDevice.chargerType {
489
            MeterInfoRowView(
490
                label: "Type",
491
                value: chargerType.title
492
            )
493
        }
Bogdan Timofte authored a month ago
494
        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
Bogdan Timofte authored a month ago
495
            MeterInfoRowView(
Bogdan Timofte authored a month ago
496
                label: "Observed Voltages",
497
                value: chargedDevice.chargerObservedVoltageSelections
498
                    .map { "\($0.format(decimalDigits: 1)) V" }
499
                    .joined(separator: ", ")
Bogdan Timofte authored a month ago
500
            )
Bogdan Timofte authored a month ago
501
        }
502
        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
Bogdan Timofte authored a month ago
503
            MeterInfoRowView(
Bogdan Timofte authored a month ago
504
                label: "Idle Current",
505
                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
Bogdan Timofte authored a month ago
506
            )
Bogdan Timofte authored a month ago
507
        }
508
        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
Bogdan Timofte authored a month ago
509
            MeterInfoRowView(
Bogdan Timofte authored a month ago
510
                label: "Efficiency",
511
                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
Bogdan Timofte authored a month ago
512
            )
Bogdan Timofte authored a month ago
513
        }
514
        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
515
            MeterInfoRowView(
516
                label: "Max Power",
517
                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
518
            )
519
        }
Bogdan Timofte authored a month ago
520
        if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
521
            MeterInfoRowView(
522
                label: "Standby Power",
523
                value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W"
524
            )
525
            MeterInfoRowView(
526
                label: "Standby Projection",
527
                value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year"
528
            )
529
        }
Bogdan Timofte authored a month ago
530
        MeterInfoRowView(
531
            label: "Wireless Sessions",
532
            value: "\(chargedDevice.sessionCount)"
533
        )
Bogdan Timofte authored a month ago
534

            
535
    }
536

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

            
540
        return MeterInfoCardView(
541
            title: "Standby Power",
542
            tint: .orange
543
        ) {
544
            if standbyMeasurementMeters.isEmpty {
545
                Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
546
                    .font(.footnote)
547
                    .foregroundColor(.secondary)
548
                    .frame(maxWidth: .infinity, alignment: .leading)
549
            } else {
550
                NavigationLink(
551
                    destination: ChargerStandbyPowerWizardView(
552
                        preferredChargerID: chargedDevice.id,
553
                        locksChargerSelection: true
554
                    )
555
                ) {
556
                    Label("New Measurement", systemImage: "plus.circle.fill")
557
                        .font(.subheadline.weight(.semibold))
558
                        .foregroundColor(.orange)
559
                }
560
                .buttonStyle(.plain)
561
            }
562

            
563
            if let latestMeasurement {
564
                Divider()
565

            
566
                NavigationLink(
567
                    destination: ChargerStandbyPowerMeasurementDetailView(
568
                        chargerID: chargedDevice.id,
569
                        measurementID: latestMeasurement.id
570
                    )
571
                ) {
572
                    VStack(alignment: .leading, spacing: 8) {
573
                        HStack {
574
                            Text("Latest Measurement")
575
                                .font(.subheadline.weight(.semibold))
576
                                .foregroundColor(.primary)
577
                            Spacer()
578
                            Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
579
                                .font(.subheadline.weight(.bold))
580
                                .foregroundColor(.primary)
581
                                .monospacedDigit()
582
                        }
583

            
584
                        Text(
585
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
586
                        )
587
                        .font(.caption)
588
                        .foregroundColor(.secondary)
589
                    }
590
                }
591
                .buttonStyle(.plain)
592
            }
593

            
594
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
595
                Divider()
596

            
597
                NavigationLink(
598
                    destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
599
                ) {
600
                    Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
601
                        .font(.subheadline.weight(.semibold))
602
                        .foregroundColor(.blue)
603
                }
604
                .buttonStyle(.plain)
605
            }
606
        }
607
    }
608

            
Bogdan Timofte authored a month ago
609
    private func activeSessionSummaryCard(
Bogdan Timofte authored a month ago
610
        _ activeSession: ChargeSessionSummary,
611
        chargedDevice: ChargedDeviceSummary
612
    ) -> some View {
Bogdan Timofte authored a month ago
613
        NavigationLink(
Bogdan Timofte authored a month ago
614
            destination: ChargeSessionDetailView(
615
                chargedDeviceID: chargedDevice.id,
616
                sessionID: activeSession.id
617
            )
Bogdan Timofte authored a month ago
618
        ) {
619
            VStack(alignment: .leading, spacing: 14) {
620
                HStack(alignment: .firstTextBaseline) {
621
                    VStack(alignment: .leading, spacing: 4) {
622
                        Text("Current Session")
623
                            .font(.headline)
624
                            .foregroundColor(.primary)
625
                        Text(activeSession.status.title)
626
                            .font(.caption.weight(.semibold))
627
                            .foregroundColor(statusTint(for: activeSession))
628
                    }
Bogdan Timofte authored a month ago
629

            
Bogdan Timofte authored a month ago
630
                    Spacer()
Bogdan Timofte authored a month ago
631

            
Bogdan Timofte authored a month ago
632
                    Image(systemName: "chevron.right")
633
                        .font(.caption.weight(.semibold))
634
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
635
                }
636

            
Bogdan Timofte authored a month ago
637
                LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) {
638
                    activeSessionMetricCell(
639
                        label: "Energy",
640
                        value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
641
                        tint: .teal
642
                    )
643
                    activeSessionMetricCell(
644
                        label: "Duration",
645
                        value: sessionDurationText(activeSession),
646
                        tint: .orange
647
                    )
648
                    if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
649
                        activeSessionMetricCell(
650
                            label: "Max Power",
651
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W",
652
                            tint: .blue
653
                        )
654
                    }
655
                    if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
656
                        activeSessionMetricCell(
657
                            label: "Battery",
658
                            value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%",
659
                            tint: .green
660
                        )
661
                    } else if let targetBatteryPercent = activeSession.targetBatteryPercent {
662
                        activeSessionMetricCell(
663
                            label: "Target",
664
                            value: "\(targetBatteryPercent.format(decimalDigits: 0))%",
665
                            tint: .indigo
666
                        )
667
                    }
Bogdan Timofte authored a month ago
668
                }
669

            
Bogdan Timofte authored a month ago
670
                Text("Started \(activeSession.startedAt.format())")
671
                    .font(.caption)
Bogdan Timofte authored a month ago
672
                    .foregroundColor(.secondary)
673
            }
Bogdan Timofte authored a month ago
674
        }
675
        .buttonStyle(.plain)
676
        .padding(18)
677
        .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
678
    }
Bogdan Timofte authored a month ago
679

            
Bogdan Timofte authored a month ago
680
    private var activeSessionSummaryColumns: [GridItem] {
681
        [
682
            GridItem(.flexible(minimum: 92), spacing: 8),
683
            GridItem(.flexible(minimum: 92), spacing: 8)
684
        ]
685
    }
Bogdan Timofte authored a month ago
686

            
Bogdan Timofte authored a month ago
687
    private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View {
688
        VStack(alignment: .leading, spacing: 4) {
689
            Text(label)
690
                .font(.caption2)
691
                .foregroundColor(.secondary)
692
            Text(value)
693
                .font(.footnote.weight(.semibold))
694
                .foregroundColor(.primary)
695
                .monospacedDigit()
696
                .lineLimit(1)
697
                .minimumScaleFactor(0.8)
Bogdan Timofte authored a month ago
698
        }
Bogdan Timofte authored a month ago
699
        .frame(maxWidth: .infinity, alignment: .leading)
700
        .padding(10)
701
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
Bogdan Timofte authored a month ago
702
    }
703

            
704
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
705
        VStack(alignment: .leading, spacing: 12) {
706
            Text("Capacity Evolution")
707
                .font(.headline)
708

            
709
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
710
                HStack {
711
                    Text(point.timestamp.format())
712
                        .font(.caption)
713
                        .foregroundColor(.secondary)
714
                    Spacer()
Bogdan Timofte authored a month ago
715
                    if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
716
                        Text(point.chargingTransportMode.title)
717
                            .font(.caption2)
718
                            .foregroundColor(.secondary)
719
                        Text("•")
720
                            .foregroundColor(.secondary)
721
                    }
Bogdan Timofte authored a month ago
722
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
723
                        .font(.footnote.weight(.semibold))
724
                }
725
            }
726
        }
727
        .frame(maxWidth: .infinity, alignment: .leading)
728
        .padding(18)
729
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
730
    }
731

            
732
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
733
        VStack(alignment: .leading, spacing: 12) {
734
            Text("Typical Charge Curve")
735
                .font(.headline)
736

            
737
            ForEach(chargedDevice.typicalCurve) { point in
738
                HStack {
739
                    Text("\(point.percentBin)%")
740
                        .font(.footnote.weight(.semibold))
741
                    Spacer()
742
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
743
                        .font(.caption.weight(.semibold))
744
                    Text("•")
745
                        .foregroundColor(.secondary)
746
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
747
                        .font(.caption2)
748
                        .foregroundColor(.secondary)
749
                }
750
            }
751
        }
752
        .frame(maxWidth: .infinity, alignment: .leading)
753
        .padding(18)
754
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
755
    }
756

            
Bogdan Timofte authored a month ago
757
    private func sessionListCard(
758
        _ sessions: [ChargeSessionSummary],
759
        chargedDevice: ChargedDeviceSummary
760
    ) -> some View {
Bogdan Timofte authored a month ago
761
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
Bogdan Timofte authored a month ago
762
        let completedCount = sessions.filter { $0.status == .completed }.count
Bogdan Timofte authored a month ago
763
        let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt }
Bogdan Timofte authored a month ago
764

            
Bogdan Timofte authored a month ago
765
        return VStack(alignment: .leading, spacing: 14) {
766
            MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
767
                MeterInfoRowView(label: "Sessions", value: "\(sessions.count)")
768
                MeterInfoRowView(label: "Completed", value: "\(completedCount)")
769
                MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
770
            }
Bogdan Timofte authored a month ago
771

            
Bogdan Timofte authored a month ago
772
            HStack(spacing: 12) {
773
                if sessionSelectMode && !selectedSessionIDs.isEmpty {
774
                    Text("\(selectedSessionIDs.count) selected")
775
                        .font(.subheadline)
776
                        .foregroundColor(.secondary)
777
                        .transition(.opacity.combined(with: .move(edge: .leading)))
778
                }
779
                Spacer()
780
                if sessionSelectMode && !selectedSessionIDs.isEmpty {
781
                    Button {
782
                        pendingBatchDeletion = true
783
                    } label: {
784
                        Image(systemName: "trash")
785
                            .foregroundColor(.red)
786
                    }
787
                    .transition(.opacity.combined(with: .scale))
788
                }
789
                Button(sessionSelectMode ? "Cancel" : "Select") {
790
                    withAnimation(.easeInOut(duration: 0.2)) {
791
                        sessionSelectMode.toggle()
792
                        if !sessionSelectMode { selectedSessionIDs.removeAll() }
793
                    }
794
                }
795
            }
796
            .animation(.easeInOut(duration: 0.2), value: sessionSelectMode)
797
            .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty)
798

            
Bogdan Timofte authored a month ago
799
            VStack(spacing: 10) {
Bogdan Timofte authored a month ago
800
                ForEach(sortedSessions, id: \.id) { session in
Bogdan Timofte authored a month ago
801
                    sessionListItem(session, chargedDevice: chargedDevice)
802
                }
Bogdan Timofte authored a month ago
803
            }
804
        }
805
    }
806

            
Bogdan Timofte authored a month ago
807
    private func sessionListItem(
808
        _ session: ChargeSessionSummary,
809
        chargedDevice: ChargedDeviceSummary
810
    ) -> some View {
811
        let sessionTint = statusTint(for: session)
Bogdan Timofte authored a month ago
812
        let isOpen = session.status.isOpen
813
        let isSelected = selectedSessionIDs.contains(session.id)
814

            
815
        return Group {
816
            if sessionSelectMode && !isOpen {
817
                Button {
818
                    withAnimation(.easeInOut(duration: 0.15)) {
819
                        if isSelected { selectedSessionIDs.remove(session.id) }
820
                        else          { selectedSessionIDs.insert(session.id) }
821
                    }
822
                } label: {
823
                    sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: isSelected)
824
                }
825
                .buttonStyle(.plain)
826
            } else {
827
                NavigationLink(
828
                    destination: ChargeSessionDetailView(
829
                        chargedDeviceID: chargedDevice.id,
830
                        sessionID: session.id
831
                    )
832
                ) {
833
                    sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: false)
834
                }
835
                .buttonStyle(.plain)
836
            }
837
        }
838
    }
Bogdan Timofte authored a month ago
839

            
Bogdan Timofte authored a month ago
840
    private func sessionRowContent(
841
        _ session: ChargeSessionSummary,
842
        sessionTint: Color,
843
        isOpen: Bool,
844
        isSelected: Bool
845
    ) -> some View {
846
        VStack(alignment: .leading, spacing: 10) {
847
            HStack(alignment: .firstTextBaseline, spacing: 10) {
848
                if sessionSelectMode {
849
                    Group {
850
                        if isOpen {
851
                            Image(systemName: "minus.circle")
852
                                .foregroundColor(.secondary.opacity(0.35))
853
                        } else {
854
                            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
855
                                .foregroundColor(isSelected ? .teal : .secondary)
856
                        }
Bogdan Timofte authored a month ago
857
                    }
Bogdan Timofte authored a month ago
858
                    .font(.body)
859
                    .transition(.opacity)
860
                }
Bogdan Timofte authored a month ago
861

            
Bogdan Timofte authored a month ago
862
                VStack(alignment: .leading, spacing: 2) {
863
                    Text(session.startedAt.format())
864
                        .font(.subheadline.weight(.semibold))
865
                    Text(session.status.title)
866
                        .font(.caption2)
867
                        .foregroundColor(sessionTint)
868
                }
Bogdan Timofte authored a month ago
869

            
Bogdan Timofte authored a month ago
870
                Spacer()
871

            
872
                VStack(alignment: .trailing, spacing: 2) {
873
                    Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
874
                        .font(.subheadline.weight(.semibold))
875
                        .foregroundColor(.primary)
876
                    Text(sessionDurationText(session))
877
                        .font(.caption)
878
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
879
                }
Bogdan Timofte authored a month ago
880
            }
Bogdan Timofte authored a month ago
881

            
Bogdan Timofte authored a month ago
882
            Divider()
Bogdan Timofte authored a month ago
883

            
Bogdan Timofte authored a month ago
884
            HStack(spacing: 8) {
885
                if let batteryDelta = session.batteryDeltaPercent {
886
                    Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
887
                        .font(.caption2)
888
                        .foregroundColor(.secondary)
889
                }
Bogdan Timofte authored a month ago
890

            
Bogdan Timofte authored a month ago
891
                if let capacityWh = session.capacityEstimateWh {
892
                    Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
893
                        .font(.caption2)
894
                        .foregroundColor(.secondary)
895
                }
Bogdan Timofte authored a month ago
896

            
Bogdan Timofte authored a month ago
897
                Spacer()
Bogdan Timofte authored a month ago
898

            
Bogdan Timofte authored a month ago
899
                if !session.displayedAggregatedSamples.isEmpty {
900
                    Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
901
                        .font(.caption2)
902
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
903
                }
904
            }
905
        }
Bogdan Timofte authored a month ago
906
        .padding(12)
907
        .meterCard(
908
            tint: sessionTint,
909
            fillOpacity: isSelected ? 0.16 : (isOpen ? 0.14 : 0.08),
910
            strokeOpacity: isSelected ? 0.22 : (isOpen ? 0.30 : 0.14),
911
            cornerRadius: 14
912
        )
Bogdan Timofte authored a month ago
913
    }
914

            
Bogdan Timofte authored a month ago
915
    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
916
        chargedDevice.sessions.filter { !$0.status.isOpen }
Bogdan Timofte authored a month ago
917
    }
918

            
Bogdan Timofte authored a month ago
919
    private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
920
        if chargedDevice.isCharger {
Bogdan Timofte authored a month ago
921
            return [.overview, .standby, .settings]
Bogdan Timofte authored a month ago
922
        }
Bogdan Timofte authored a month ago
923
        return [.overview, .sessions, .trends, .settings]
924
    }
Bogdan Timofte authored a month ago
925

            
Bogdan Timofte authored a month ago
926
    private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
927
        if tabs.contains(selectedTab) {
928
            return selectedTab
Bogdan Timofte authored a month ago
929
        }
Bogdan Timofte authored a month ago
930
        return tabs.first ?? .overview
931
    }
932

            
933
    private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
934
        let tabs = availableTabs(for: chargedDevice)
935
        if !tabs.contains(selectedTab) {
936
            selectedTab = tabs.first ?? .overview
Bogdan Timofte authored a month ago
937
        }
Bogdan Timofte authored a month ago
938
    }
939

            
940
    private func title(for tab: DetailTab) -> String {
941
        switch tab {
942
        case .overview:
943
            return "Overview"
944
        case .standby:
945
            return "Standby"
946
        case .sessions:
947
            return "Sessions"
948
        case .trends:
949
            return "Trends"
950
        case .settings:
951
            return "Settings"
Bogdan Timofte authored a month ago
952
        }
Bogdan Timofte authored a month ago
953
    }
Bogdan Timofte authored a month ago
954

            
Bogdan Timofte authored a month ago
955
    private func systemImage(for tab: DetailTab) -> String {
956
        switch tab {
957
        case .overview:
958
            return "house.fill"
959
        case .standby:
960
            return "bolt.badge.clock"
961
        case .sessions:
962
            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
963
        case .trends:
964
            return "chart.xyaxis.line"
965
        case .settings:
966
            return "gearshape.fill"
967
        }
968
    }
969

            
970
    private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
971
        LinearGradient(
972
            colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
973
            startPoint: .topLeading,
974
            endPoint: .bottomTrailing
975
        )
976
        .ignoresSafeArea()
Bogdan Timofte authored a month ago
977
    }
978

            
979
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
980
        switch chargedDevice.deviceClass {
981
        case .iphone:
982
            return .blue
983
        case .watch:
984
            return .green
985
        case .powerbank:
986
            return .orange
987
        case .charger:
988
            return .pink
989
        case .other:
990
            return .secondary
991
        }
992
    }
993

            
Bogdan Timofte authored a month ago
994
    private func statusTint(for session: ChargeSessionSummary) -> Color {
995
        switch session.status {
996
        case .active:
997
            return .green
998
        case .paused:
999
            return .orange
1000
        case .completed:
1001
            return .teal
1002
        case .abandoned:
1003
            return .secondary
1004
        }
1005
    }
1006

            
1007
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
1008
        let formatter = DateComponentsFormatter()
1009
        let effectiveDuration = max(session.effectiveDuration, 0)
1010
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
1011
        formatter.unitsStyle = .abbreviated
1012
        formatter.zeroFormattingBehavior = .dropAll
1013
        return formatter.string(from: effectiveDuration) ?? "0m"
1014
    }
1015

            
Bogdan Timofte authored a month ago
1016
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
1017
        if wattHours >= 1000 {
1018
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
1019
        }
1020
        return "\(wattHours.format(decimalDigits: 2)) Wh"
1021
    }
1022

            
1023
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
1024
        appData.meterSummaries.filter { $0.meter != nil }
1025
    }
1026

            
Bogdan Timofte authored a month ago
1027
    private func completionCurrentDescription(
1028
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
1029
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
1030
    ) -> String {
Bogdan Timofte authored a month ago
1031
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
1032
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
1033
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
1034
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
1035
            }
1036
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
1037
        }
1038

            
Bogdan Timofte authored a month ago
1039
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
1040
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
1041
        }
1042

            
1043
        return "Learning"
1044
    }
1045

            
Bogdan Timofte authored a month ago
1046
    private func completionCurrentLabel(
1047
        for chargedDevice: ChargedDeviceSummary,
1048
        sessionKind: ChargeSessionKind
1049
    ) -> String {
1050
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
1051
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
1052

            
1053
        switch (showsTransport, showsState) {
1054
        case (true, true):
1055
            return "\(sessionKind.shortTitle) Stop Current"
1056
        case (true, false):
1057
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
1058
        case (false, true):
1059
            return "\(sessionKind.chargingStateMode.title) Stop Current"
1060
        case (false, false):
1061
            return "Stop Current"
1062
        }
1063
    }
1064

            
Bogdan Timofte authored a month ago
1065
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
1066
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
1067
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
1068
                ChargeSessionKind(
1069
                    chargingTransportMode: chargingTransportMode,
1070
                    chargingStateMode: chargingStateMode
1071
                )
1072
            }
1073
        }
1074
    }
1075

            
Bogdan Timofte authored a month ago
1076
    private func deleteSelectedSessions() {
1077
        for id in selectedSessionIDs {
1078
            _ = appData.deleteChargeSession(sessionID: id)
1079
        }
1080
        selectedSessionIDs.removeAll()
1081
        sessionSelectMode = false
1082
    }
1083

            
Bogdan Timofte authored a month ago
1084
    private func showEditor() {
1085
        editorVisibility = true
Bogdan Timofte authored a month ago
1086
    }
1087

            
Bogdan Timofte authored a month ago
1088
    private func showDeleteConfirmation() {
1089
        deleteConfirmationVisibility = true
Bogdan Timofte authored a month ago
1090
    }
1091

            
Bogdan Timofte authored a month ago
1092
    private var deletionTitle: String {
1093
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
1094
    }
1095

            
1096
    private var deletionMessage: String {
1097
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
1098
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
1099
        }
1100
        return "This removes the device and its stored charging history from the library."
1101
    }
1102

            
Bogdan Timofte authored a month ago
1103
}