USB-Meter / USB Meter / Views / ChargedDevices / Details / ChargedDeviceDetailView.swift
Newer Older
1102 lines | 43.453kb
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
        }
42
        .sheet(isPresented: $editorVisibility) {
43
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
44
                if chargedDevice.isCharger {
Bogdan Timofte authored a month ago
45
                    ChargerEditorSheetView(chargedDevice: chargedDevice)
46
                        .environmentObject(appData)
Bogdan Timofte authored a month ago
47
                } else {
48
                    ChargedDeviceEditorSheetView(
49
                        meterMACAddress: nil,
50
                        chargedDevice: chargedDevice
51
                    )
52
                    .environmentObject(appData)
53
                }
Bogdan Timofte authored a month ago
54
            }
55
        }
56
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
57
            Button("Delete", role: .destructive) {
58
                if appData.deleteChargedDevice(id: chargedDeviceID) {
59
                    dismiss()
60
                }
61
            }
62
            Button("Cancel", role: .cancel) {}
63
        } message: {
64
            Text(deletionMessage)
65
        }
Bogdan Timofte authored a month ago
66
        .confirmationDialog(
67
            "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?",
68
            isPresented: $pendingBatchDeletion,
69
            titleVisibility: .visible
70
        ) {
71
            Button("Delete", role: .destructive, action: deleteSelectedSessions)
72
            Button("Cancel", role: .cancel) {}
73
        } message: {
74
            Text("Deleting these sessions also recalculates capacity and every derived metric that used them.")
75
        }
Bogdan Timofte authored a month ago
76
    }
77

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
371
            Divider()
372

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

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

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

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

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

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

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

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

            
534
    }
535

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

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

            
562
            if let latestMeasurement {
563
                Divider()
564

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
869
                Spacer()
870

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1042
        return "Learning"
1043
    }
1044

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1102
}