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

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

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

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

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

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

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

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

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

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

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

            
184
        if !chargedDevice.typicalCurve.isEmpty {
185
            typicalCurveCard(chargedDevice)
186
        }
187

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
374
            Divider()
375

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

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

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

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

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

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

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

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

            
537
    }
538

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

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

            
565
            if let latestMeasurement {
566
                Divider()
567

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

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

            
596
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
597
                Divider()
598

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

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

            
Bogdan Timofte authored a month ago
632
                    Spacer()
Bogdan Timofte authored a month ago
633

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
872
                Spacer()
873

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

            
Bogdan Timofte authored a month ago
884
            Divider()
Bogdan Timofte authored a month ago
885

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

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

            
Bogdan Timofte authored a month ago
899
                Spacer()
Bogdan Timofte authored a month ago
900

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1045
        return "Learning"
1046
    }
1047

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1105
}