USB-Meter / USB Meter / Views / ChargedDevices / Details / ChargedDeviceDetailView.swift
Newer Older
828 lines | 32.456kb
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

            
26
    let chargedDeviceID: UUID
27

            
28
    var body: some View {
29
        Group {
30
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
31
                tabbedDetailView(chargedDevice)
Bogdan Timofte authored a month ago
32
                .navigationTitle(chargedDevice.name)
33
                .toolbar {
34
                    ToolbarItemGroup(placement: .primaryAction) {
Bogdan Timofte authored a month ago
35
                        Button("Edit", action: showEditor)
36
                        Button(role: .destructive, action: showDeleteConfirmation) {
Bogdan Timofte authored a month ago
37
                            Image(systemName: "trash")
38
                        }
39
                    }
40
                }
41
            } else {
42
                Text("This device is no longer available.")
43
                    .foregroundColor(.secondary)
44
                    .navigationTitle("Device")
45
            }
46
        }
47
        .sheet(isPresented: $editorVisibility) {
48
            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
49
                if chargedDevice.isCharger {
Bogdan Timofte authored a month ago
50
                    ChargerEditorSheetView(chargedDevice: chargedDevice)
51
                        .environmentObject(appData)
Bogdan Timofte authored a month ago
52
                } else {
53
                    ChargedDeviceEditorSheetView(
54
                        meterMACAddress: nil,
55
                        chargedDevice: chargedDevice
56
                    )
57
                    .environmentObject(appData)
58
                }
Bogdan Timofte authored a month ago
59
            }
60
        }
61
        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
62
            Button("Delete", role: .destructive) {
63
                if appData.deleteChargedDevice(id: chargedDeviceID) {
64
                    dismiss()
65
                }
66
            }
67
            Button("Cancel", role: .cancel) {}
68
        } message: {
69
            Text(deletionMessage)
70
        }
71
    }
72

            
Bogdan Timofte authored a month ago
73
    private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View {
74
        GeometryReader { proxy in
75
            let tabs = availableTabs(for: chargedDevice)
76
            let displayedTab = displayedTab(from: tabs)
77
            let tabBarPresentation = AdaptiveTabBarPresentation.standard(for: proxy.size)
78

            
79
            VStack(spacing: 0) {
80
                ChargedDeviceDetailTabBarView(
81
                    tabs: tabs,
82
                    selection: $selectedTab,
83
                    tint: tint(for: chargedDevice),
84
                    presentation: tabBarPresentation,
85
                    title: title(for:),
86
                    systemImage: systemImage(for:)
87
                )
88

            
89
                ScrollView {
90
                    tabContent(displayedTab, chargedDevice: chargedDevice)
91
                        .padding()
92
                }
93
                .id(displayedTab)
94
                .transition(.opacity.combined(with: .move(edge: .trailing)))
95
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
96
            }
97
            .animation(.easeInOut(duration: 0.22), value: displayedTab)
98
            .animation(.easeInOut(duration: 0.22), value: tabs)
99
        }
100
        .background(detailBackground(for: chargedDevice))
101
        .onAppear {
102
            ensureSelectedTabExists(for: chargedDevice)
103
        }
104
        .onChange(of: chargedDevice.isCharger) { _ in
105
            ensureSelectedTabExists(for: chargedDevice)
106
        }
107
    }
108

            
109
    @ViewBuilder
110
    private func tabContent(_ tab: DetailTab, chargedDevice: ChargedDeviceSummary) -> some View {
111
        VStack(spacing: 18) {
112
            switch tab {
113
            case .overview:
114
                overviewTab(chargedDevice)
115
            case .standby:
116
                standbyTab(chargedDevice)
117
            case .sessions:
118
                sessionsTab(chargedDevice)
119
            case .trends:
120
                trendsTab(chargedDevice)
121
            case .settings:
122
                settingsTab(chargedDevice)
123
            }
124
        }
125
    }
126

            
127
    @ViewBuilder
128
    private func overviewTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
129
        headerCard(chargedDevice)
130
        insightsCard(chargedDevice)
131

            
132
        if let activeSession = chargedDevice.activeSession {
133
            activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice)
134
        }
135
    }
136

            
137
    @ViewBuilder
138
    private func standbyTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
139
        standbyPowerCard(chargedDevice)
140
    }
141

            
142
    @ViewBuilder
143
    private func sessionsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
144
        if let activeSession = chargedDevice.activeSession {
145
            activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice)
146
        }
147

            
148
        if !closedSessions(for: chargedDevice).isEmpty {
149
            sessionHistorySummaryCard(chargedDevice)
150
        } else if chargedDevice.activeSession == nil {
151
            emptyStateCard(
152
                title: "No Sessions",
153
                message: "Charging sessions will appear here after this \(chargedDevice.isCharger ? "charger" : "device") is used in a recording.",
154
                tint: .teal
155
            )
156
        }
157
    }
158

            
159
    @ViewBuilder
160
    private func trendsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
161
        if !chargedDevice.capacityHistory.isEmpty {
162
            capacityEvolutionCard(chargedDevice)
163
        }
164

            
165
        if !chargedDevice.typicalCurve.isEmpty {
166
            typicalCurveCard(chargedDevice)
167
        }
168

            
169
        if chargedDevice.capacityHistory.isEmpty && chargedDevice.typicalCurve.isEmpty {
170
            emptyStateCard(
171
                title: "Learning Trends",
172
                message: "Capacity history and charge curves will appear after enough completed sessions are available.",
173
                tint: .blue
174
            )
175
        }
176
    }
177

            
178
    @ViewBuilder
179
    private func settingsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
180
        settingsCard(chargedDevice)
181
    }
182

            
Bogdan Timofte authored a month ago
183
    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
184
        HStack(alignment: .top, spacing: 18) {
185
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
186

            
187
            VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
188
                ChargedDeviceIdentityLabelView(
189
                    chargedDevice: chargedDevice,
190
                    iconPointSize: 22
191
                )
192
                .font(.title3.weight(.bold))
Bogdan Timofte authored a month ago
193

            
Bogdan Timofte authored a month ago
194
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
195
                    .font(.subheadline.weight(.semibold))
196
                    .foregroundColor(.secondary)
197

            
198
                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
199
                    Text("Default meter: \(meterMAC)")
200
                        .font(.caption)
201
                        .foregroundColor(.secondary)
202
                }
203

            
204
                Text(chargedDevice.qrIdentifier)
205
                    .font(.caption2.monospaced())
206
                    .foregroundColor(.secondary)
207
                    .textSelection(.enabled)
208
            }
209

            
210
            Spacer(minLength: 0)
211
        }
212
        .frame(maxWidth: .infinity, alignment: .leading)
213
        .padding(18)
214
        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
215
    }
216

            
Bogdan Timofte authored a month ago
217
    private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
218
        MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) {
219
            MeterInfoRowView(
220
                label: "Kind",
221
                value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title
222
            )
223
            MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle)
224
            MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier)
225

            
226
            if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
227
                MeterInfoRowView(label: "Default Meter", value: meterMAC)
228
            }
229

            
230
            MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format())
231
            MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format())
232

            
233
            Divider()
234

            
235
            Button(action: showEditor) {
236
                Label("Edit \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "pencil")
237
                    .font(.subheadline.weight(.semibold))
238
                    .frame(maxWidth: .infinity)
239
                    .padding(.vertical, 10)
240
                    .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
241
            }
242
            .buttonStyle(.plain)
243

            
244
            Button(role: .destructive, action: showDeleteConfirmation) {
245
                Label("Delete \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "trash")
246
                    .font(.subheadline.weight(.semibold))
247
                    .frame(maxWidth: .infinity)
248
                    .padding(.vertical, 10)
249
                    .meterCard(tint: .red, fillOpacity: 0.10, strokeOpacity: 0.18, cornerRadius: 14)
250
            }
251
            .buttonStyle(.plain)
252
        }
253
    }
254

            
255
    private func emptyStateCard(title: String, message: String, tint: Color) -> some View {
256
        VStack(alignment: .leading, spacing: 8) {
257
            Text(title)
258
                .font(.headline)
259
            Text(message)
260
                .font(.footnote)
261
                .foregroundColor(.secondary)
262
                .fixedSize(horizontal: false, vertical: true)
263
        }
264
        .frame(maxWidth: .infinity, alignment: .leading)
265
        .padding(18)
266
        .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
267
    }
268

            
Bogdan Timofte authored a month ago
269
    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
270
        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
Bogdan Timofte authored a month ago
271
            if chargedDevice.isCharger {
272
                chargerInsights(chargedDevice)
273
            } else {
274
                deviceInsights(chargedDevice)
275
            }
276

            
277
            if let notes = chargedDevice.notes, !notes.isEmpty {
278
                Divider()
279
                Text(notes)
280
                    .font(.footnote)
281
                    .foregroundColor(.secondary)
282
                    .frame(maxWidth: .infinity, alignment: .leading)
283
            }
284
        }
285
    }
286

            
287
    @ViewBuilder
288
    private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
289
        if chargedDevice.hasMultipleChargingStateModes {
290
            MeterInfoRowView(
291
                label: "Charge Modes",
292
                value: chargedDevice.chargingStateAvailability.title
293
            )
294
        }
295
        if chargedDevice.hasMultipleChargingTransports {
296
            MeterInfoRowView(
297
                label: "Charging Support",
298
                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
299
            )
300
        }
301
        if chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
302
            MeterInfoRowView(
Bogdan Timofte authored a month ago
303
                label: "Wireless Profile",
304
                value: chargedDevice.wirelessChargingProfile.title
Bogdan Timofte authored a month ago
305
            )
Bogdan Timofte authored a month ago
306
        }
307

            
308
        ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
Bogdan Timofte authored a month ago
309
            MeterInfoRowView(
Bogdan Timofte authored a month ago
310
                label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind),
Bogdan Timofte authored a month ago
311
                value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
Bogdan Timofte authored a month ago
312
            )
Bogdan Timofte authored a month ago
313
        }
314
        MeterInfoRowView(
315
            label: "Estimated Capacity",
316
            value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
317
        )
318
        if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
319
            if chargedDevice.hasMultipleChargingTransports {
320
                MeterInfoRowView(
321
                    label: "Wired Capacity",
322
                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
323
                )
324
            }
Bogdan Timofte authored a month ago
325
        }
326
        if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
Bogdan Timofte authored a month ago
327
            if chargedDevice.hasMultipleChargingTransports {
328
                MeterInfoRowView(
329
                    label: "Wireless Capacity",
330
                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
331
                )
332
            }
Bogdan Timofte authored a month ago
333
        }
Bogdan Timofte authored a month ago
334
        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor,
335
           chargedDevice.showsWirelessProfileDetails {
Bogdan Timofte authored a month ago
336
            MeterInfoRowView(
337
                label: "Wireless Efficiency",
338
                value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
339
            )
340
        }
341
        MeterInfoRowView(
342
            label: "Charge Sessions",
343
            value: "\(chargedDevice.sessionCount)"
344
        )
345
    }
Bogdan Timofte authored a month ago
346

            
Bogdan Timofte authored a month ago
347
    @ViewBuilder
348
    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
Bogdan Timofte authored a month ago
349
        if let chargerType = chargedDevice.chargerType {
350
            MeterInfoRowView(
351
                label: "Type",
352
                value: chargerType.title
353
            )
354
        }
Bogdan Timofte authored a month ago
355
        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
Bogdan Timofte authored a month ago
356
            MeterInfoRowView(
Bogdan Timofte authored a month ago
357
                label: "Observed Voltages",
358
                value: chargedDevice.chargerObservedVoltageSelections
359
                    .map { "\($0.format(decimalDigits: 1)) V" }
360
                    .joined(separator: ", ")
Bogdan Timofte authored a month ago
361
            )
Bogdan Timofte authored a month ago
362
        }
363
        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
Bogdan Timofte authored a month ago
364
            MeterInfoRowView(
Bogdan Timofte authored a month ago
365
                label: "Idle Current",
366
                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
Bogdan Timofte authored a month ago
367
            )
Bogdan Timofte authored a month ago
368
        }
369
        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
Bogdan Timofte authored a month ago
370
            MeterInfoRowView(
Bogdan Timofte authored a month ago
371
                label: "Efficiency",
372
                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
Bogdan Timofte authored a month ago
373
            )
Bogdan Timofte authored a month ago
374
        }
375
        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
376
            MeterInfoRowView(
377
                label: "Max Power",
378
                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
379
            )
380
        }
Bogdan Timofte authored a month ago
381
        if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
382
            MeterInfoRowView(
383
                label: "Standby Power",
384
                value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W"
385
            )
386
            MeterInfoRowView(
387
                label: "Standby Projection",
388
                value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year"
389
            )
390
        }
Bogdan Timofte authored a month ago
391
        MeterInfoRowView(
392
            label: "Wireless Sessions",
393
            value: "\(chargedDevice.sessionCount)"
394
        )
Bogdan Timofte authored a month ago
395

            
Bogdan Timofte authored a month ago
396
        if chargedDevice.chargerIdleCurrentAmps == nil {
397
            Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
398
                .font(.caption2)
399
                .foregroundColor(.orange)
Bogdan Timofte authored a month ago
400
        }
401
    }
402

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

            
406
        return MeterInfoCardView(
407
            title: "Standby Power",
408
            tint: .orange
409
        ) {
410
            if standbyMeasurementMeters.isEmpty {
411
                Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
412
                    .font(.footnote)
413
                    .foregroundColor(.secondary)
414
                    .frame(maxWidth: .infinity, alignment: .leading)
415
            } else {
416
                NavigationLink(
417
                    destination: ChargerStandbyPowerWizardView(
418
                        preferredChargerID: chargedDevice.id,
419
                        locksChargerSelection: true
420
                    )
421
                ) {
422
                    Label("New Measurement", systemImage: "plus.circle.fill")
423
                        .font(.subheadline.weight(.semibold))
424
                        .foregroundColor(.orange)
425
                }
426
                .buttonStyle(.plain)
427
            }
428

            
429
            if let latestMeasurement {
430
                Divider()
431

            
432
                NavigationLink(
433
                    destination: ChargerStandbyPowerMeasurementDetailView(
434
                        chargerID: chargedDevice.id,
435
                        measurementID: latestMeasurement.id
436
                    )
437
                ) {
438
                    VStack(alignment: .leading, spacing: 8) {
439
                        HStack {
440
                            Text("Latest Measurement")
441
                                .font(.subheadline.weight(.semibold))
442
                                .foregroundColor(.primary)
443
                            Spacer()
444
                            Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
445
                                .font(.subheadline.weight(.bold))
446
                                .foregroundColor(.primary)
447
                                .monospacedDigit()
448
                        }
449

            
450
                        Text(
451
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
452
                        )
453
                        .font(.caption)
454
                        .foregroundColor(.secondary)
455
                    }
456
                }
457
                .buttonStyle(.plain)
458
            }
459

            
460
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
461
                Divider()
462

            
463
                NavigationLink(
464
                    destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id)
465
                ) {
466
                    Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
467
                        .font(.subheadline.weight(.semibold))
468
                        .foregroundColor(.blue)
469
                }
470
                .buttonStyle(.plain)
471
            }
472
        }
473
    }
474

            
Bogdan Timofte authored a month ago
475
    private func activeSessionSummaryCard(
Bogdan Timofte authored a month ago
476
        _ activeSession: ChargeSessionSummary,
477
        chargedDevice: ChargedDeviceSummary
478
    ) -> some View {
Bogdan Timofte authored a month ago
479
        NavigationLink(
Bogdan Timofte authored a month ago
480
            destination: ChargeSessionDetailView(
481
                chargedDeviceID: chargedDevice.id,
482
                sessionID: activeSession.id
483
            )
Bogdan Timofte authored a month ago
484
        ) {
485
            VStack(alignment: .leading, spacing: 14) {
486
                HStack(alignment: .firstTextBaseline) {
487
                    VStack(alignment: .leading, spacing: 4) {
488
                        Text("Current Session")
489
                            .font(.headline)
490
                            .foregroundColor(.primary)
491
                        Text(activeSession.status.title)
492
                            .font(.caption.weight(.semibold))
493
                            .foregroundColor(statusTint(for: activeSession))
494
                    }
Bogdan Timofte authored a month ago
495

            
Bogdan Timofte authored a month ago
496
                    Spacer()
Bogdan Timofte authored a month ago
497

            
Bogdan Timofte authored a month ago
498
                    Image(systemName: "chevron.right")
499
                        .font(.caption.weight(.semibold))
500
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
501
                }
502

            
Bogdan Timofte authored a month ago
503
                LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) {
504
                    activeSessionMetricCell(
505
                        label: "Energy",
506
                        value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
507
                        tint: .teal
508
                    )
509
                    activeSessionMetricCell(
510
                        label: "Duration",
511
                        value: sessionDurationText(activeSession),
512
                        tint: .orange
513
                    )
514
                    if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
515
                        activeSessionMetricCell(
516
                            label: "Max Power",
517
                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W",
518
                            tint: .blue
519
                        )
520
                    }
521
                    if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
522
                        activeSessionMetricCell(
523
                            label: "Battery",
524
                            value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%",
525
                            tint: .green
526
                        )
527
                    } else if let targetBatteryPercent = activeSession.targetBatteryPercent {
528
                        activeSessionMetricCell(
529
                            label: "Target",
530
                            value: "\(targetBatteryPercent.format(decimalDigits: 0))%",
531
                            tint: .indigo
532
                        )
533
                    }
Bogdan Timofte authored a month ago
534
                }
535

            
Bogdan Timofte authored a month ago
536
                Text("Started \(activeSession.startedAt.format())")
537
                    .font(.caption)
Bogdan Timofte authored a month ago
538
                    .foregroundColor(.secondary)
539
            }
Bogdan Timofte authored a month ago
540
        }
541
        .buttonStyle(.plain)
542
        .padding(18)
543
        .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
544
    }
Bogdan Timofte authored a month ago
545

            
Bogdan Timofte authored a month ago
546
    private var activeSessionSummaryColumns: [GridItem] {
547
        [
548
            GridItem(.flexible(minimum: 92), spacing: 8),
549
            GridItem(.flexible(minimum: 92), spacing: 8)
550
        ]
551
    }
Bogdan Timofte authored a month ago
552

            
Bogdan Timofte authored a month ago
553
    private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View {
554
        VStack(alignment: .leading, spacing: 4) {
555
            Text(label)
556
                .font(.caption2)
557
                .foregroundColor(.secondary)
558
            Text(value)
559
                .font(.footnote.weight(.semibold))
560
                .foregroundColor(.primary)
561
                .monospacedDigit()
562
                .lineLimit(1)
563
                .minimumScaleFactor(0.8)
Bogdan Timofte authored a month ago
564
        }
Bogdan Timofte authored a month ago
565
        .frame(maxWidth: .infinity, alignment: .leading)
566
        .padding(10)
567
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
Bogdan Timofte authored a month ago
568
    }
569

            
570
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
571
        VStack(alignment: .leading, spacing: 12) {
572
            Text("Capacity Evolution")
573
                .font(.headline)
574

            
575
            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
576
                HStack {
577
                    Text(point.timestamp.format())
578
                        .font(.caption)
579
                        .foregroundColor(.secondary)
580
                    Spacer()
Bogdan Timofte authored a month ago
581
                    if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
582
                        Text(point.chargingTransportMode.title)
583
                            .font(.caption2)
584
                            .foregroundColor(.secondary)
585
                        Text("•")
586
                            .foregroundColor(.secondary)
587
                    }
Bogdan Timofte authored a month ago
588
                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
589
                        .font(.footnote.weight(.semibold))
590
                }
591
            }
592
        }
593
        .frame(maxWidth: .infinity, alignment: .leading)
594
        .padding(18)
595
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
596
    }
597

            
598
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
599
        VStack(alignment: .leading, spacing: 12) {
600
            Text("Typical Charge Curve")
601
                .font(.headline)
602

            
603
            ForEach(chargedDevice.typicalCurve) { point in
604
                HStack {
605
                    Text("\(point.percentBin)%")
606
                        .font(.footnote.weight(.semibold))
607
                    Spacer()
608
                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
609
                        .font(.caption.weight(.semibold))
610
                    Text("•")
611
                        .foregroundColor(.secondary)
612
                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
613
                        .font(.caption2)
614
                        .foregroundColor(.secondary)
615
                }
616
            }
617
        }
618
        .frame(maxWidth: .infinity, alignment: .leading)
619
        .padding(18)
620
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
621
    }
622

            
Bogdan Timofte authored a month ago
623
    private func sessionHistorySummaryCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
624
        let sessions = closedSessions(for: chargedDevice)
625
        let latestSession = sessions.first
626
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
Bogdan Timofte authored a month ago
627

            
Bogdan Timofte authored a month ago
628
        return MeterInfoCardView(title: "Session History", tint: .teal) {
629
            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
630
            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
631
            if let latestSession {
632
                MeterInfoRowView(label: "Latest", value: latestSession.startedAt.format())
633
            }
Bogdan Timofte authored a month ago
634

            
Bogdan Timofte authored a month ago
635
            NavigationLink(
636
                destination: ChargedDeviceSessionsView(chargedDeviceID: chargedDevice.id)
637
            ) {
638
                Label("Manage Sessions", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
639
                    .font(.subheadline.weight(.semibold))
640
                    .frame(maxWidth: .infinity)
641
                    .padding(.vertical, 10)
642
                    .meterCard(tint: .teal, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
Bogdan Timofte authored a month ago
643
            }
Bogdan Timofte authored a month ago
644
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
645
        }
646
    }
647

            
Bogdan Timofte authored a month ago
648
    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
649
        chargedDevice.sessions.filter { !$0.status.isOpen }
Bogdan Timofte authored a month ago
650
    }
651

            
Bogdan Timofte authored a month ago
652
    private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
653
        if chargedDevice.isCharger {
654
            return [.overview, .standby, .sessions, .settings]
Bogdan Timofte authored a month ago
655
        }
Bogdan Timofte authored a month ago
656
        return [.overview, .sessions, .trends, .settings]
657
    }
Bogdan Timofte authored a month ago
658

            
Bogdan Timofte authored a month ago
659
    private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
660
        if tabs.contains(selectedTab) {
661
            return selectedTab
Bogdan Timofte authored a month ago
662
        }
Bogdan Timofte authored a month ago
663
        return tabs.first ?? .overview
664
    }
665

            
666
    private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
667
        let tabs = availableTabs(for: chargedDevice)
668
        if !tabs.contains(selectedTab) {
669
            selectedTab = tabs.first ?? .overview
Bogdan Timofte authored a month ago
670
        }
Bogdan Timofte authored a month ago
671
    }
672

            
673
    private func title(for tab: DetailTab) -> String {
674
        switch tab {
675
        case .overview:
676
            return "Overview"
677
        case .standby:
678
            return "Standby"
679
        case .sessions:
680
            return "Sessions"
681
        case .trends:
682
            return "Trends"
683
        case .settings:
684
            return "Settings"
Bogdan Timofte authored a month ago
685
        }
Bogdan Timofte authored a month ago
686
    }
Bogdan Timofte authored a month ago
687

            
Bogdan Timofte authored a month ago
688
    private func systemImage(for tab: DetailTab) -> String {
689
        switch tab {
690
        case .overview:
691
            return "house.fill"
692
        case .standby:
693
            return "bolt.badge.clock"
694
        case .sessions:
695
            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
696
        case .trends:
697
            return "chart.xyaxis.line"
698
        case .settings:
699
            return "gearshape.fill"
700
        }
701
    }
702

            
703
    private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
704
        LinearGradient(
705
            colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
706
            startPoint: .topLeading,
707
            endPoint: .bottomTrailing
708
        )
709
        .ignoresSafeArea()
Bogdan Timofte authored a month ago
710
    }
711

            
712
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
713
        switch chargedDevice.deviceClass {
714
        case .iphone:
715
            return .blue
716
        case .watch:
717
            return .green
718
        case .powerbank:
719
            return .orange
720
        case .charger:
721
            return .pink
722
        case .other:
723
            return .secondary
724
        }
725
    }
726

            
Bogdan Timofte authored a month ago
727
    private func statusTint(for session: ChargeSessionSummary) -> Color {
728
        switch session.status {
729
        case .active:
730
            return .green
731
        case .paused:
732
            return .orange
733
        case .completed:
734
            return .teal
735
        case .abandoned:
736
            return .secondary
737
        }
738
    }
739

            
740
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
741
        let formatter = DateComponentsFormatter()
742
        let effectiveDuration = max(session.effectiveDuration, 0)
743
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
744
        formatter.unitsStyle = .abbreviated
745
        formatter.zeroFormattingBehavior = .dropAll
746
        return formatter.string(from: effectiveDuration) ?? "0m"
747
    }
748

            
Bogdan Timofte authored a month ago
749
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
750
        if wattHours >= 1000 {
751
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
752
        }
753
        return "\(wattHours.format(decimalDigits: 2)) Wh"
754
    }
755

            
756
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
757
        appData.meterSummaries.filter { $0.meter != nil }
758
    }
759

            
Bogdan Timofte authored a month ago
760
    private func completionCurrentDescription(
761
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
762
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
763
    ) -> String {
Bogdan Timofte authored a month ago
764
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
765
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
766
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
767
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
768
            }
769
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
770
        }
771

            
Bogdan Timofte authored a month ago
772
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
773
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
774
        }
775

            
776
        return "Learning"
777
    }
778

            
Bogdan Timofte authored a month ago
779
    private func completionCurrentLabel(
780
        for chargedDevice: ChargedDeviceSummary,
781
        sessionKind: ChargeSessionKind
782
    ) -> String {
783
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
784
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
785

            
786
        switch (showsTransport, showsState) {
787
        case (true, true):
788
            return "\(sessionKind.shortTitle) Stop Current"
789
        case (true, false):
790
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
791
        case (false, true):
792
            return "\(sessionKind.chargingStateMode.title) Stop Current"
793
        case (false, false):
794
            return "Stop Current"
795
        }
796
    }
797

            
Bogdan Timofte authored a month ago
798
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
799
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
800
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
801
                ChargeSessionKind(
802
                    chargingTransportMode: chargingTransportMode,
803
                    chargingStateMode: chargingStateMode
804
                )
805
            }
806
        }
807
    }
808

            
Bogdan Timofte authored a month ago
809
    private func showEditor() {
810
        editorVisibility = true
Bogdan Timofte authored a month ago
811
    }
812

            
Bogdan Timofte authored a month ago
813
    private func showDeleteConfirmation() {
814
        deleteConfirmationVisibility = true
Bogdan Timofte authored a month ago
815
    }
816

            
Bogdan Timofte authored a month ago
817
    private var deletionTitle: String {
818
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
819
    }
820

            
821
    private var deletionMessage: String {
822
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
823
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
824
        }
825
        return "This removes the device and its stored charging history from the library."
826
    }
827

            
Bogdan Timofte authored a month ago
828
}