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

            
8
import SwiftUI
9

            
10
struct ChargedDeviceSettingsView: View {
11
    private enum DetailTab: Hashable {
12
        case overview
13
        case standby
14
        case sessions
15
        case trends
16
        case settings
17
    }
18

            
19
    @EnvironmentObject private var appData: AppData
20
    @Environment(\.dismiss) private var dismiss
21

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

            
29
    let chargedDeviceID: UUID
30

            
31
    var body: some View {
32
        Group {
33
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
34
                tabbedDetailView(chargedDevice)
35
                .navigationTitle(chargedDevice.name)
36
                .navigationBarTitleDisplayMode(.inline)
37
            } else {
38
                Text("This device is no longer available.")
39
                    .foregroundColor(.secondary)
40
                    .navigationTitle("Device")
41
                    .navigationBarTitleDisplayMode(.inline)
42
            }
43
        }
44
        .sidebarToggleToolbarItem()
45
        .sheet(isPresented: $editorVisibility) {
46
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
47
                if chargedDevice.isCharger {
48
                    ChargerEditorSheetView(chargedDevice: chargedDevice)
49
                        .environmentObject(appData)
50
                } else {
Bogdan Timofte authored a month ago
51
                    ChargedDeviceEditorSheetView(chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
52
                    .environmentObject(appData)
53
                }
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
        }
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
        }
76
    }
77

            
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

            
94
                Group {
95
                    if displayedTab == .sessions {
96
                        sessionsTabLayout(chargedDevice)
97
                    } else {
98
                        ScrollView {
99
                            tabContent(displayedTab, chargedDevice: chargedDevice)
100
                                .padding()
101
                        }
102
                    }
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)
110
            .onChange(of: selectedTab) { _ in
111
                sessionSelectMode = false
112
                selectedSessionIDs.removeAll()
113
            }
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

            
163
        let sessions = closedSessions(for: chargedDevice)
164
        if !sessions.isEmpty {
165
            sessionListCard(sessions, chargedDevice: chargedDevice)
166
        } else if chargedDevice.activeSession == nil {
167
            emptyStateCard(
168
                title: "No Sessions",
169
                message: "Charging sessions will appear here after this device is used in a recording.",
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

            
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

            
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) {
326
                ChargedDeviceIdentityLabelView(
327
                    chargedDevice: chargedDevice,
328
                    iconPointSize: 22
329
                )
330
                .font(.title3.weight(.bold))
331

            
332
                Text(chargedDevice.identityTitle)
333
                    .font(.subheadline.weight(.semibold))
334
                    .foregroundColor(.secondary)
335

            
336
                Text(chargedDevice.qrIdentifier)
337
                    .font(.caption2.monospaced())
338
                    .foregroundColor(.secondary)
339
                    .textSelection(.enabled)
340
            }
341

            
342
            Spacer(minLength: 0)
343
        }
344
        .frame(maxWidth: .infinity, alignment: .leading)
345
        .padding(18)
346
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
347
    }
348

            
349
    private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
350
        MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) {
351
            MeterInfoRowView(
352
                label: "Kind",
353
                value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title
354
            )
355
            MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle)
356
            MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier)
357

            
Bogdan Timofte authored a month ago
358
            if chargedDevice.supportsInternalSubject {
359
                MeterInfoRowView(
360
                    label: "Subject",
361
                    value: chargedDevice.hasInternalSubject ? "Inside" : "Empty"
362
                )
363
            }
364

            
Bogdan Timofte authored a month ago
365
            MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format())
366
            MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format())
367

            
368
            Divider()
369

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

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

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

            
404
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
405
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
406
            if chargedDevice.isCharger {
407
                chargerInsights(chargedDevice)
408
            } else {
409
                deviceInsights(chargedDevice)
410
            }
411

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

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

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

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

            
531
    }
532

            
533
    private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
534
        let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement
535

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

            
559
            if let latestMeasurement {
560
                Divider()
561

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

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

            
590
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
591
                Divider()
592

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

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

            
626
                    Spacer()
627

            
628
                    Image(systemName: "chevron.right")
629
                        .font(.caption.weight(.semibold))
630
                        .foregroundColor(.secondary)
631
                }
632

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

            
666
                Text("Started \(activeSession.startedAt.format())")
667
                    .font(.caption)
668
                    .foregroundColor(.secondary)
669
            }
670
        }
671
        .buttonStyle(.plain)
672
        .padding(18)
673
        .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
674
    }
675

            
676
    private var activeSessionSummaryColumns: [GridItem] {
677
        [
678
            GridItem(.flexible(minimum: 92), spacing: 8),
679
            GridItem(.flexible(minimum: 92), spacing: 8)
680
        ]
681
    }
682

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

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

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

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

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

            
753
    private func sessionListCard(
754
        _ sessions: [ChargeSessionSummary],
755
        chargedDevice: ChargedDeviceSummary
756
    ) -> some View {
757
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
758
        let completedCount = sessions.filter { $0.status == .completed }.count
759
        let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt }
760

            
761
        return VStack(alignment: .leading, spacing: 14) {
762
            MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
763
                MeterInfoRowView(label: "Sessions", value: "\(sessions.count)")
764
                MeterInfoRowView(label: "Completed", value: "\(completedCount)")
765
                MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
766
            }
767

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

            
795
            VStack(spacing: 10) {
796
                ForEach(sortedSessions, id: \.id) { session in
797
                    sessionListItem(session, chargedDevice: chargedDevice)
798
                }
799
            }
800
        }
801
    }
802

            
803
    private func sessionListItem(
804
        _ session: ChargeSessionSummary,
805
        chargedDevice: ChargedDeviceSummary
806
    ) -> some View {
807
        let sessionTint = statusTint(for: session)
808
        let isOpen = session.status.isOpen
809
        let isSelected = selectedSessionIDs.contains(session.id)
810

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

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

            
858
                VStack(alignment: .leading, spacing: 2) {
859
                    Text(session.startedAt.format())
860
                        .font(.subheadline.weight(.semibold))
861
                    Text(session.status.title)
862
                        .font(.caption2)
863
                        .foregroundColor(sessionTint)
864
                }
865

            
866
                Spacer()
867

            
868
                VStack(alignment: .trailing, spacing: 2) {
869
                    Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
870
                        .font(.subheadline.weight(.semibold))
871
                        .foregroundColor(.primary)
872
                    Text(sessionDurationText(session))
873
                        .font(.caption)
874
                        .foregroundColor(.secondary)
875
                }
876
            }
877

            
878
            Divider()
879

            
880
            HStack(spacing: 8) {
881
                if let batteryDelta = session.batteryDeltaPercent {
882
                    Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
883
                        .font(.caption2)
884
                        .foregroundColor(.secondary)
885
                }
886

            
887
                if let capacityWh = session.capacityEstimateWh {
888
                    Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
889
                        .font(.caption2)
890
                        .foregroundColor(.secondary)
891
                }
892

            
893
                Spacer()
894

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

            
911
    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
912
        chargedDevice.sessions.filter { !$0.status.isOpen }
913
    }
914

            
915
    private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
916
        if chargedDevice.isCharger {
917
            return [.overview, .standby, .settings]
918
        }
919
        return [.overview, .sessions, .trends, .settings]
920
    }
921

            
922
    private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
923
        if tabs.contains(selectedTab) {
924
            return selectedTab
925
        }
926
        return tabs.first ?? .overview
927
    }
928

            
929
    private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
930
        let tabs = availableTabs(for: chargedDevice)
931
        if !tabs.contains(selectedTab) {
932
            selectedTab = tabs.first ?? .overview
933
        }
934
    }
935

            
936
    private func title(for tab: DetailTab) -> String {
937
        switch tab {
938
        case .overview:
939
            return "Overview"
940
        case .standby:
941
            return "Standby"
942
        case .sessions:
943
            return "Sessions"
944
        case .trends:
945
            return "Trends"
946
        case .settings:
947
            return "Settings"
948
        }
949
    }
950

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

            
966
    private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
967
        LinearGradient(
968
            colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
969
            startPoint: .topLeading,
970
            endPoint: .bottomTrailing
971
        )
972
        .ignoresSafeArea()
973
    }
974

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

            
990
    private func statusTint(for session: ChargeSessionSummary) -> Color {
991
        switch session.status {
992
        case .active:
993
            return .green
994
        case .paused:
995
            return .orange
996
        case .completed:
997
            return .teal
998
        case .abandoned:
999
            return .secondary
1000
        }
1001
    }
1002

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

            
1012
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
1013
        if wattHours >= 1000 {
1014
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
1015
        }
1016
        return "\(wattHours.format(decimalDigits: 2)) Wh"
1017
    }
1018

            
1019
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
1020
        appData.meterSummaries.filter { $0.meter != nil }
1021
    }
1022

            
1023
    private func completionCurrentDescription(
1024
        for chargedDevice: ChargedDeviceSummary,
1025
        sessionKind: ChargeSessionKind
1026
    ) -> String {
1027
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
1028
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
1029
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
1030
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
1031
            }
1032
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
1033
        }
1034

            
1035
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
1036
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
1037
        }
1038

            
1039
        return "Learning"
1040
    }
1041

            
1042
    private func completionCurrentLabel(
1043
        for chargedDevice: ChargedDeviceSummary,
1044
        sessionKind: ChargeSessionKind
1045
    ) -> String {
1046
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
1047
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
1048

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

            
1061
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
1062
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
1063
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
1064
                ChargeSessionKind(
1065
                    chargingTransportMode: chargingTransportMode,
1066
                    chargingStateMode: chargingStateMode
1067
                )
1068
            }
1069
        }
1070
    }
1071

            
1072
    private func deleteSelectedSessions() {
1073
        for id in selectedSessionIDs {
1074
            _ = appData.deleteChargeSession(sessionID: id)
1075
        }
1076
        selectedSessionIDs.removeAll()
1077
        sessionSelectMode = false
1078
    }
1079

            
1080
    private func showEditor() {
1081
        editorVisibility = true
1082
    }
1083

            
1084
    private func showDeleteConfirmation() {
1085
        deleteConfirmationVisibility = true
1086
    }
1087

            
1088
    private var deletionTitle: String {
1089
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
1090
    }
1091

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

            
1099
}