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

            
Bogdan Timofte authored a month ago
148
        let sessions = closedSessions(for: chargedDevice)
149
        if !sessions.isEmpty {
150
            sessionListCard(sessions, chargedDevice: chargedDevice)
Bogdan Timofte authored a month ago
151
        } else if chargedDevice.activeSession == nil {
152
            emptyStateCard(
153
                title: "No Sessions",
Bogdan Timofte authored a month ago
154
                message: "Charging sessions will appear here after this device is used in a recording.",
Bogdan Timofte authored a month ago
155
                tint: .teal
156
            )
157
        }
158
    }
159

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

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

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

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

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

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

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

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

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

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

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

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

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

            
234
            Divider()
235

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

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

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

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

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

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

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

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

            
397
    }
398

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

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

            
425
            if let latestMeasurement {
426
                Divider()
427

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

            
446
                        Text(
447
                            "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year"
448
                        )
449
                        .font(.caption)
450
                        .foregroundColor(.secondary)
451
                    }
452
                }
453
                .buttonStyle(.plain)
454
            }
455

            
456
            if chargedDevice.standbyPowerMeasurements.isEmpty == false {
457
                Divider()
458

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

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

            
Bogdan Timofte authored a month ago
492
                    Spacer()
Bogdan Timofte authored a month ago
493

            
Bogdan Timofte authored a month ago
494
                    Image(systemName: "chevron.right")
495
                        .font(.caption.weight(.semibold))
496
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
497
                }
498

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

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

            
Bogdan Timofte authored a month ago
542
    private var activeSessionSummaryColumns: [GridItem] {
543
        [
544
            GridItem(.flexible(minimum: 92), spacing: 8),
545
            GridItem(.flexible(minimum: 92), spacing: 8)
546
        ]
547
    }
Bogdan Timofte authored a month ago
548

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

            
566
    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
567
        VStack(alignment: .leading, spacing: 12) {
568
            Text("Capacity Evolution")
569
                .font(.headline)
570

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

            
594
    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
595
        VStack(alignment: .leading, spacing: 12) {
596
            Text("Typical Charge Curve")
597
                .font(.headline)
598

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

            
Bogdan Timofte authored a month ago
619
    private func sessionListCard(
620
        _ sessions: [ChargeSessionSummary],
621
        chargedDevice: ChargedDeviceSummary
622
    ) -> some View {
Bogdan Timofte authored a month ago
623
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
Bogdan Timofte authored a month ago
624
        let completedCount = sessions.filter { $0.status == .completed }.count
Bogdan Timofte authored a month ago
625

            
Bogdan Timofte authored a month ago
626
        return VStack(alignment: .leading, spacing: 14) {
627
            MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
628
                MeterInfoRowView(label: "Sessions", value: "\(sessions.count)")
629
                MeterInfoRowView(label: "Completed", value: "\(completedCount)")
630
                MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
Bogdan Timofte authored a month ago
631
            }
Bogdan Timofte authored a month ago
632

            
Bogdan Timofte authored a month ago
633
            VStack(spacing: 10) {
634
                ForEach(sessions.sorted { $0.startedAt > $1.startedAt }, id: \.id) { session in
635
                    sessionListItem(session, chargedDevice: chargedDevice)
636
                }
Bogdan Timofte authored a month ago
637
            }
638
        }
639
    }
640

            
Bogdan Timofte authored a month ago
641
    private func sessionListItem(
642
        _ session: ChargeSessionSummary,
643
        chargedDevice: ChargedDeviceSummary
644
    ) -> some View {
645
        let sessionTint = statusTint(for: session)
646

            
647
        return NavigationLink(
648
            destination: ChargeSessionDetailView(
649
                chargedDeviceID: chargedDevice.id,
650
                sessionID: session.id
651
            )
652
        ) {
653
            VStack(alignment: .leading, spacing: 10) {
654
                HStack(alignment: .firstTextBaseline, spacing: 10) {
655
                    VStack(alignment: .leading, spacing: 2) {
656
                        Text(session.startedAt.format())
657
                            .font(.subheadline.weight(.semibold))
658
                        Text(session.status.title)
659
                            .font(.caption2)
660
                            .foregroundColor(sessionTint)
661
                    }
662

            
663
                    Spacer()
664

            
665
                    VStack(alignment: .trailing, spacing: 2) {
666
                        Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
667
                            .font(.subheadline.weight(.semibold))
668
                            .foregroundColor(.primary)
669
                        Text(sessionDurationText(session))
670
                            .font(.caption)
671
                            .foregroundColor(.secondary)
672
                    }
673
                }
674

            
675
                Divider()
676

            
677
                HStack(spacing: 8) {
678
                    if let batteryDelta = session.batteryDeltaPercent {
679
                        Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
680
                            .font(.caption2)
681
                            .foregroundColor(.secondary)
682
                    }
683

            
684
                    if let capacityWh = session.capacityEstimateWh {
685
                        Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
686
                            .font(.caption2)
687
                            .foregroundColor(.secondary)
688
                    }
689

            
690
                    Spacer()
691

            
692
                    if !session.displayedAggregatedSamples.isEmpty {
693
                        Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
694
                            .font(.caption2)
695
                            .foregroundColor(.secondary)
696
                    }
697
                }
698
            }
699
            .padding(12)
700
            .meterCard(tint: sessionTint, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14)
701
        }
702
        .buttonStyle(.plain)
703
    }
704

            
Bogdan Timofte authored a month ago
705
    private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
706
        chargedDevice.sessions.filter { !$0.status.isOpen }
Bogdan Timofte authored a month ago
707
    }
708

            
Bogdan Timofte authored a month ago
709
    private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
710
        if chargedDevice.isCharger {
Bogdan Timofte authored a month ago
711
            return [.overview, .standby, .settings]
Bogdan Timofte authored a month ago
712
        }
Bogdan Timofte authored a month ago
713
        return [.overview, .sessions, .trends, .settings]
714
    }
Bogdan Timofte authored a month ago
715

            
Bogdan Timofte authored a month ago
716
    private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
717
        if tabs.contains(selectedTab) {
718
            return selectedTab
Bogdan Timofte authored a month ago
719
        }
Bogdan Timofte authored a month ago
720
        return tabs.first ?? .overview
721
    }
722

            
723
    private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
724
        let tabs = availableTabs(for: chargedDevice)
725
        if !tabs.contains(selectedTab) {
726
            selectedTab = tabs.first ?? .overview
Bogdan Timofte authored a month ago
727
        }
Bogdan Timofte authored a month ago
728
    }
729

            
730
    private func title(for tab: DetailTab) -> String {
731
        switch tab {
732
        case .overview:
733
            return "Overview"
734
        case .standby:
735
            return "Standby"
736
        case .sessions:
737
            return "Sessions"
738
        case .trends:
739
            return "Trends"
740
        case .settings:
741
            return "Settings"
Bogdan Timofte authored a month ago
742
        }
Bogdan Timofte authored a month ago
743
    }
Bogdan Timofte authored a month ago
744

            
Bogdan Timofte authored a month ago
745
    private func systemImage(for tab: DetailTab) -> String {
746
        switch tab {
747
        case .overview:
748
            return "house.fill"
749
        case .standby:
750
            return "bolt.badge.clock"
751
        case .sessions:
752
            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
753
        case .trends:
754
            return "chart.xyaxis.line"
755
        case .settings:
756
            return "gearshape.fill"
757
        }
758
    }
759

            
760
    private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
761
        LinearGradient(
762
            colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
763
            startPoint: .topLeading,
764
            endPoint: .bottomTrailing
765
        )
766
        .ignoresSafeArea()
Bogdan Timofte authored a month ago
767
    }
768

            
769
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
770
        switch chargedDevice.deviceClass {
771
        case .iphone:
772
            return .blue
773
        case .watch:
774
            return .green
775
        case .powerbank:
776
            return .orange
777
        case .charger:
778
            return .pink
779
        case .other:
780
            return .secondary
781
        }
782
    }
783

            
Bogdan Timofte authored a month ago
784
    private func statusTint(for session: ChargeSessionSummary) -> Color {
785
        switch session.status {
786
        case .active:
787
            return .green
788
        case .paused:
789
            return .orange
790
        case .completed:
791
            return .teal
792
        case .abandoned:
793
            return .secondary
794
        }
795
    }
796

            
797
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
798
        let formatter = DateComponentsFormatter()
799
        let effectiveDuration = max(session.effectiveDuration, 0)
800
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
801
        formatter.unitsStyle = .abbreviated
802
        formatter.zeroFormattingBehavior = .dropAll
803
        return formatter.string(from: effectiveDuration) ?? "0m"
804
    }
805

            
Bogdan Timofte authored a month ago
806
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
807
        if wattHours >= 1000 {
808
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
809
        }
810
        return "\(wattHours.format(decimalDigits: 2)) Wh"
811
    }
812

            
813
    private var standbyMeasurementMeters: [AppData.MeterSummary] {
814
        appData.meterSummaries.filter { $0.meter != nil }
815
    }
816

            
Bogdan Timofte authored a month ago
817
    private func completionCurrentDescription(
818
        for chargedDevice: ChargedDeviceSummary,
Bogdan Timofte authored a month ago
819
        sessionKind: ChargeSessionKind
Bogdan Timofte authored a month ago
820
    ) -> String {
Bogdan Timofte authored a month ago
821
        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
822
            if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind),
Bogdan Timofte authored a month ago
823
               abs(configuredCurrent - learnedCurrent) >= 0.01 {
824
                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
825
            }
826
            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
827
        }
828

            
Bogdan Timofte authored a month ago
829
        if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
Bogdan Timofte authored a month ago
830
            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
831
        }
832

            
833
        return "Learning"
834
    }
835

            
Bogdan Timofte authored a month ago
836
    private func completionCurrentLabel(
837
        for chargedDevice: ChargedDeviceSummary,
838
        sessionKind: ChargeSessionKind
839
    ) -> String {
840
        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
841
        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
842

            
843
        switch (showsTransport, showsState) {
844
        case (true, true):
845
            return "\(sessionKind.shortTitle) Stop Current"
846
        case (true, false):
847
            return "\(sessionKind.chargingTransportMode.title) Stop Current"
848
        case (false, true):
849
            return "\(sessionKind.chargingStateMode.title) Stop Current"
850
        case (false, false):
851
            return "Stop Current"
852
        }
853
    }
854

            
Bogdan Timofte authored a month ago
855
    private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
856
        chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
857
            chargedDevice.supportedChargingStateModes.map { chargingStateMode in
858
                ChargeSessionKind(
859
                    chargingTransportMode: chargingTransportMode,
860
                    chargingStateMode: chargingStateMode
861
                )
862
            }
863
        }
864
    }
865

            
Bogdan Timofte authored a month ago
866
    private func showEditor() {
867
        editorVisibility = true
Bogdan Timofte authored a month ago
868
    }
869

            
Bogdan Timofte authored a month ago
870
    private func showDeleteConfirmation() {
871
        deleteConfirmationVisibility = true
Bogdan Timofte authored a month ago
872
    }
873

            
Bogdan Timofte authored a month ago
874
    private var deletionTitle: String {
875
        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
876
    }
877

            
878
    private var deletionMessage: String {
879
        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
880
            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
881
        }
882
        return "This removes the device and its stored charging history from the library."
883
    }
884

            
Bogdan Timofte authored a month ago
885
}