USB-Meter / USB Meter / Views / Meter / Tabs / Live / ChargerStandbyPowerWizardView.swift
Newer Older
925 lines | 38.53kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargerStandbyPowerWizardView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 13/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
struct ChargerStandbyPowerWizardView: View {
11
    @EnvironmentObject private var appData: AppData
12

            
13
    @State private var chargerLibraryVisibility = false
14
    @State private var discardConfirmationVisibility = false
15
    @State private var selectedMeterMACAddress: String?
16
    @State private var selectedChargerID: UUID?
17

            
18
    let preferredMeterMACAddress: String?
19
    let preferredChargerID: UUID?
20
    let locksChargerSelection: Bool
21

            
22
    init(
23
        preferredMeterMACAddress: String? = nil,
24
        preferredChargerID: UUID? = nil,
25
        locksChargerSelection: Bool = false
26
    ) {
27
        self.preferredMeterMACAddress = preferredMeterMACAddress
28
        self.preferredChargerID = preferredChargerID
29
        self.locksChargerSelection = locksChargerSelection
30
        _selectedMeterMACAddress = State(initialValue: nil)
31
        _selectedChargerID = State(initialValue: preferredChargerID)
32
    }
33

            
34
    var body: some View {
35
        ScrollView {
36
            VStack(spacing: 18) {
37
                if let session = activeSession {
38
                    activeMeasurementCard(session)
39
                    liveSessionCard(session)
40
                } else {
41
                    newMeasurementWizardCard
42
                }
43
            }
44
            .padding()
45
        }
46
        .background(
47
            LinearGradient(
48
                colors: [.orange.opacity(0.16), Color.clear],
49
                startPoint: .topLeading,
50
                endPoint: .bottomTrailing
51
            )
52
            .ignoresSafeArea()
53
        )
54
        .navigationTitle(navigationTitleText)
55
        .sheet(isPresented: $chargerLibraryVisibility) {
56
            ChargedDeviceLibrarySheetView(
57
                meterMACAddress: selectedMeterSummary?.macAddress ?? "",
58
                meterTint: selectedMeter?.color ?? .orange,
59
                mode: .charger
60
            )
61
            .environmentObject(appData)
62
        }
63
        .confirmationDialog(
64
            "Discard the current standby measurement?",
65
            isPresented: $discardConfirmationVisibility,
66
            titleVisibility: .visible
67
        ) {
68
            Button("Discard", role: .destructive) {
69
                if let activeSession {
70
                    _ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false)
71
                }
72
            }
73
            Button("Cancel", role: .cancel) {}
74
        } message: {
75
            Text("The current sample set will be removed and nothing will be saved for this charger.")
76
        }
77
    }
78

            
79
    private var liveMeterSummaries: [AppData.MeterSummary] {
80
        appData.meterSummaries.filter { $0.meter != nil }
81
    }
82

            
83
    private var availableChargers: [ChargedDeviceSummary] {
84
        appData.chargerSummaries
85
    }
86

            
87
    private var preferredChargerMeterMACAddress: String? {
88
        preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
89
    }
90

            
91
    private var activeSession: ChargerStandbyPowerMonitorSession? {
92
        let candidateMACAddresses = [
93
            selectedMeterMACAddress ?? "",
94
            preferredMeterMACAddress ?? "",
95
            preferredChargerMeterMACAddress ?? ""
96
        ]
97
        .filter { $0.isEmpty == false }
98

            
99
        for macAddress in candidateMACAddresses {
100
            if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
101
                return session
102
            }
103
        }
104

            
105
        for meterSummary in liveMeterSummaries {
106
            if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
107
                return session
108
            }
109
        }
110

            
111
        return nil
112
    }
113

            
114
    private var suggestedMeterSummary: AppData.MeterSummary? {
115
        if let preferredMeterMACAddress {
116
            return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
117
        }
118

            
119
        if let preferredChargerMeterMACAddress {
120
            return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
121
        }
122

            
123
        return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil
124
    }
125

            
126
    private var selectedMeterSummary: AppData.MeterSummary? {
127
        if let activeSession {
128
            return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
129
        }
130

            
131
        guard let selectedMeterMACAddress else {
132
            return nil
133
        }
134

            
135
        return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
136
    }
137

            
138
    private var selectedMeter: Meter? {
139
        selectedMeterSummary?.meter
140
    }
141

            
142
    private var isChargerSelectionLocked: Bool {
143
        locksChargerSelection || preferredChargerID != nil
144
    }
145

            
146
    private var meterSelectionBinding: Binding<String?> {
147
        Binding(
148
            get: { selectedMeterMACAddress },
149
            set: { selectedMeterMACAddress = $0 }
150
        )
151
    }
152

            
153
    private var selectedCharger: ChargedDeviceSummary? {
154
        if let activeSession {
155
            return appData.chargedDeviceSummary(id: activeSession.chargerID)
156
        }
157

            
158
        guard let selectedChargerID else {
159
            return nil
160
        }
161

            
162
        return appData.chargedDeviceSummary(id: selectedChargerID)
163
    }
164

            
165
    private var chargerSelectionBinding: Binding<UUID?> {
166
        Binding(
167
            get: { selectedChargerID },
168
            set: { selectedChargerID = $0 }
169
        )
170
    }
171

            
172
    private var preferredChargerSummary: ChargedDeviceSummary? {
173
        guard let preferredChargerID else {
174
            return nil
175
        }
176
        return appData.chargedDeviceSummary(id: preferredChargerID)
177
    }
178

            
179
    private var navigationTitleText: String {
180
        "New Standby Consumption Measurement"
181
    }
182

            
183
    private var wizardCardTitle: String {
184
        if let selectedMeterSummary {
185
            return selectedMeterSummary.displayName
186
        }
187

            
188
        if let suggestedMeterSummary {
189
            return suggestedMeterSummary.displayName
190
        }
191

            
192
        return "Use Meter"
193
    }
194

            
195
    private var newMeasurementWizardCard: some View {
196
        MeterInfoCardView(
197
            title: wizardCardTitle,
198
            tint: .orange
199
        ) {
200
            if liveMeterSummaries.isEmpty {
201
                Text("Connect a live meter first. Standby measurement uses a live feed, so meter selection happens here in the wizard.")
202
                    .font(.footnote)
203
                    .foregroundColor(.secondary)
204
                    .frame(maxWidth: .infinity, alignment: .leading)
205
            } else {
206
                VStack(alignment: .leading, spacing: 12) {
207
                    if isChargerSelectionLocked == false {
208
                        HStack(spacing: 8) {
209
                            Text("Charger")
210
                                .font(.subheadline.weight(.semibold))
211
                            ContextInfoButton(
212
                                title: "Charger",
213
                                message: "Choose the charger whose standby consumption you want to measure in this run."
214
                            )
215
                        }
216

            
217
                        if availableChargers.isEmpty {
218
                            Text("No charger available yet. Open the charger library to create one first.")
219
                                .font(.caption)
220
                                .foregroundColor(.secondary)
221
                        } else {
222
                            Picker("Charger", selection: chargerSelectionBinding) {
223
                                Text("Select Charger").tag(Optional<UUID>.none)
224
                                ForEach(availableChargers) { charger in
225
                                    Text(charger.name).tag(Optional(charger.id))
226
                                }
227
                            }
228
                            .pickerStyle(.menu)
229
                        }
230
                    }
231

            
232
                    HStack(spacing: 8) {
233
                        Text("Use Meter")
234
                            .font(.subheadline.weight(.semibold))
235
                        ContextInfoButton(
236
                            title: "Use Meter",
237
                            message: "Choose the live meter explicitly. Standby consumption can vary with the upstream source or when the charger is connected to a computer, so re-run after setup changes."
238
                        )
239
                    }
240

            
241
                    Picker("Use Meter", selection: meterSelectionBinding) {
242
                        Text("Select Meter").tag(Optional<String>.none)
243
                        ForEach(liveMeterSummaries) { meterSummary in
244
                            Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress))
245
                        }
246
                    }
247
                    .pickerStyle(.menu)
248
                    .disabled(activeSession != nil)
249

            
250
                    if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
251
                        Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
252
                            .font(.caption)
253
                            .foregroundColor(.secondary)
254
                    }
255

            
256
                    HStack(spacing: 12) {
257
                        if isChargerSelectionLocked == false {
258
                            Button("Manage Charger Library") {
259
                                chargerLibraryVisibility = true
260
                            }
261
                            .disabled(selectedMeter == nil)
262
                        }
263

            
264
                        Button("Start Measurement") {
265
                            startMeasurement()
266
                        }
267
                        .disabled(selectedCharger == nil || selectedMeter == nil)
268
                    }
269
                    .buttonStyle(.borderedProminent)
270
                }
271
            }
272

            
273
            if selectedMeter == nil {
274
                Text("Choose the live meter explicitly before starting. The wizard no longer auto-confirms a suggested meter.")
275
                    .font(.caption)
276
                    .foregroundColor(.secondary)
277
            } else if activeSession == nil, selectedCharger == nil {
278
                Text("Select the charger you want to measure, then start the run.")
279
                    .font(.caption)
280
                    .foregroundColor(.secondary)
281
            } else if activeSession == nil, selectedMeter?.operationalState != .dataIsAvailable {
282
                Text("The wizard can start now, but samples will only be captured while live meter data is available.")
283
                    .font(.caption)
284
                    .foregroundColor(.secondary)
285
            } else if let activeSession {
286
                Text(
287
                    "\(activeSession.readinessDescription) • \(formattedDuration(Date().timeIntervalSince(activeSession.startedAt))) • \(activeSession.sampleCount) samples"
288
                )
289
                .font(.caption)
290
                .foregroundColor(.secondary)
291
            }
292
        }
293
    }
294

            
295
    private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
296
        MeterInfoCardView(
297
            title: "Measurement Running",
298
            infoMessage: "The run keeps collecting samples while this meter stays live. Save when you are happy with the sample set, or discard to cancel it.",
299
            tint: .orange
300
        ) {
301
            MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress)
302
            MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger")
303
            MeterInfoRowView(label: "Status", value: session.readinessDescription)
304
            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)")
305

            
306
            HStack(spacing: 12) {
307
                Button("Save Result") {
308
                    _ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true)
309
                }
310
                .disabled(session.hasSamples == false)
311

            
312
                Button("Discard") {
313
                    discardConfirmationVisibility = true
314
                }
315
                .foregroundColor(.red)
316
            }
317
            .buttonStyle(.borderedProminent)
318
        }
319
    }
320

            
321
    private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
322
        VStack(spacing: 18) {
323
            if let statistics = session.statistics {
324
                stabilityCard(
325
                    isStable: statistics.isStable,
326
                    averagePowerWatts: statistics.averagePowerWatts,
327
                    stabilityDeltaWatts: statistics.stabilityDeltaWatts,
328
                    stabilityToleranceWatts: statistics.stabilityToleranceWatts,
329
                    sampleCount: statistics.sampleCount
330
                )
331

            
332
                projectionCard(
333
                    averagePowerWatts: statistics.averagePowerWatts,
334
                    projectedDailyEnergyWh: statistics.projectedDailyEnergyWh,
335
                    projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh,
336
                    projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh,
337
                    projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh
338
                )
339

            
340
                distributionCard(
341
                    histogram: statistics.histogram,
342
                    averagePowerWatts: statistics.averagePowerWatts,
343
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
344
                    tint: .orange
345
                )
346

            
347
                statisticsCard(
348
                    averagePowerWatts: statistics.averagePowerWatts,
349
                    medianPowerWatts: statistics.medianPowerWatts,
350
                    minimumPowerWatts: statistics.minimumPowerWatts,
351
                    maximumPowerWatts: statistics.maximumPowerWatts,
352
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
353
                    coefficientOfVariation: statistics.coefficientOfVariation,
354
                    averageCurrentAmps: statistics.averageCurrentAmps,
355
                    averageVoltageVolts: statistics.averageVoltageVolts
356
                )
357
            } else {
358
                MeterInfoCardView(title: "Live Stats", tint: .orange) {
359
                    Text("Waiting for the first valid power samples from the meter.")
360
                        .font(.footnote)
361
                        .foregroundColor(.secondary)
362
                }
363
            }
364
        }
365
    }
366

            
367
    private func stabilityCard(
368
        isStable: Bool,
369
        averagePowerWatts: Double,
370
        stabilityDeltaWatts: Double,
371
        stabilityToleranceWatts: Double,
372
        sampleCount: Int,
373
        subtitle: String? = nil
374
    ) -> some View {
375
        VStack(alignment: .leading, spacing: 10) {
376
            HStack {
377
                VStack(alignment: .leading, spacing: 4) {
378
                    Text(isStable ? "Enough Samples" : "Still Settling")
379
                        .font(.headline)
380
                    Text(subtitle ?? (isStable ? "The running average has stabilised." : "The wizard is still watching the average drift."))
381
                        .font(.caption)
382
                        .foregroundColor(.secondary)
383
                }
384

            
385
                Spacer()
386

            
387
                Text(isStable ? "Ready" : "Live")
388
                    .font(.caption.weight(.semibold))
389
                    .padding(.horizontal, 10)
390
                    .padding(.vertical, 6)
391
                    .foregroundColor(isStable ? .green : .orange)
392
                    .meterCard(
393
                        tint: isStable ? .green : .orange,
394
                        fillOpacity: 0.10,
395
                        strokeOpacity: 0.16,
396
                        cornerRadius: 999
397
                    )
398
            }
399

            
400
            Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
401
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
402
                .monospacedDigit()
403

            
404
            Text(
405
                "Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples."
406
            )
407
            .font(.footnote)
408
            .foregroundColor(.secondary)
409
        }
410
        .frame(maxWidth: .infinity, alignment: .leading)
411
        .padding(18)
412
        .meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
413
    }
414

            
415
    private func projectionCard(
416
        averagePowerWatts: Double,
417
        projectedDailyEnergyWh: Double,
418
        projectedWeeklyEnergyWh: Double,
419
        projectedMonthlyEnergyWh: Double,
420
        projectedYearlyEnergyWh: Double
421
    ) -> some View {
422
        MeterInfoCardView(
423
            title: "Consumption Projection",
424
            infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
425
            tint: .teal
426
        ) {
427
            MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
428
            MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(projectedDailyEnergyWh))
429
            MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(projectedWeeklyEnergyWh))
430
            MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(projectedMonthlyEnergyWh))
431
            MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(projectedYearlyEnergyWh))
432
        }
433
    }
434

            
435
    private func distributionCard(
436
        histogram: [ChargerStandbyPowerDistributionBin],
437
        averagePowerWatts: Double,
438
        standardDeviationPowerWatts: Double,
439
        tint: Color
440
    ) -> some View {
441
        MeterInfoCardView(
442
            title: "Distribution",
443
            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
444
            tint: tint
445
        ) {
446
            StandbyPowerHistogramView(
447
                histogram: histogram,
448
                averagePowerWatts: averagePowerWatts,
449
                standardDeviationPowerWatts: standardDeviationPowerWatts,
450
                tint: tint
451
            )
452
            .frame(height: 220)
453

            
454
            if let firstBin = histogram.first, let lastBin = histogram.last {
Bogdan Timofte authored a month ago
455
                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
Bogdan Timofte authored a month ago
456
                HStack {
457
                    Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
458
                    Spacer()
Bogdan Timofte authored a month ago
459
                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
460
                    Spacer()
Bogdan Timofte authored a month ago
461
                    Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
462
                }
463
                .font(.caption)
464
                .foregroundColor(.secondary)
465
                .monospacedDigit()
466
            }
467
        }
468
    }
469

            
470
    private func statisticsCard(
471
        averagePowerWatts: Double,
472
        medianPowerWatts: Double,
473
        minimumPowerWatts: Double,
474
        maximumPowerWatts: Double,
475
        standardDeviationPowerWatts: Double,
476
        coefficientOfVariation: Double,
477
        averageCurrentAmps: Double,
478
        averageVoltageVolts: Double
479
    ) -> some View {
480
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
481
            MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W")
482
            MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W")
483
            MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W")
484
            MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W")
485
            MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%")
486
            MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A")
487
            MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V")
488
            MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady")
489
        }
490
    }
491

            
492
    private func startMeasurement() {
493
        guard let selectedCharger, let selectedMeter else {
494
            return
495
        }
496

            
497
        _ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter)
498
    }
499

            
500
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
501
        if wattHours >= 1000 {
502
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
503
        }
504
        return "\(wattHours.format(decimalDigits: 2)) Wh"
505
    }
506

            
507
    private func formattedDuration(_ duration: TimeInterval) -> String {
508
        let formatter = DateComponentsFormatter()
509
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
510
        formatter.unitsStyle = .abbreviated
511
        formatter.zeroFormattingBehavior = .pad
512
        return formatter.string(from: max(duration, 0)) ?? "0s"
513
    }
514

            
515
}
516

            
517
private struct StandbyPowerHistogramView: View {
518
    let histogram: [ChargerStandbyPowerDistributionBin]
519
    let averagePowerWatts: Double
520
    let standardDeviationPowerWatts: Double
521
    let tint: Color
522

            
523
    var body: some View {
524
        GeometryReader { proxy in
525
            let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1)
526

            
527
            ZStack {
528
                HStack(alignment: .bottom, spacing: 6) {
529
                    ForEach(histogram) { bin in
530
                        RoundedRectangle(cornerRadius: 8, style: .continuous)
531
                            .fill(tint.opacity(0.24))
532
                            .overlay(
533
                                RoundedRectangle(cornerRadius: 8, style: .continuous)
534
                                    .stroke(tint.opacity(0.22), lineWidth: 1)
535
                            )
536
                            .frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height))
537
                    }
538
                }
539
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
540

            
541
                gaussianCurve(in: proxy.size)
542
                    .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
543

            
544
                meanMarker(in: proxy.size)
545
                    .stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
546
            }
547
        }
548
    }
549

            
550
    private func gaussianCurve(in size: CGSize) -> Path {
551
        guard histogram.count > 1,
552
              standardDeviationPowerWatts > 0,
553
              let firstBin = histogram.first,
554
              let lastBin = histogram.last else {
555
            return Path()
556
        }
557

            
558
        let minimum = firstBin.lowerBoundWatts
559
        let maximum = lastBin.upperBoundWatts
560
        let span = max(maximum - minimum, 0.000_001)
561
        let sampleCount = 48
562
        let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi))
563

            
564
        return Path { path in
565
            for index in 0...sampleCount {
566
                let progress = Double(index) / Double(sampleCount)
567
                let value = minimum + (span * progress)
568
                let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts
569
                let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi))
570
                let normalizedHeight = density / peakDensity
571

            
572
                let x = progress * size.width
573
                let y = size.height - (normalizedHeight * (Double(size.height) * 0.92))
574
                let point = CGPoint(x: x, y: y)
575

            
576
                if index == 0 {
577
                    path.move(to: point)
578
                } else {
579
                    path.addLine(to: point)
580
                }
581
            }
582
        }
583
    }
584

            
585
    private func meanMarker(in size: CGSize) -> Path {
586
        guard let firstBin = histogram.first, let lastBin = histogram.last else {
587
            return Path()
588
        }
589

            
590
        let minimum = firstBin.lowerBoundWatts
591
        let maximum = lastBin.upperBoundWatts
592
        let span = max(maximum - minimum, 0.000_001)
593
        let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1)
594
        let x = normalizedX * size.width
595

            
596
        return Path { path in
597
            path.move(to: CGPoint(x: x, y: 0))
598
            path.addLine(to: CGPoint(x: x, y: size.height))
599
        }
600
    }
601
}
602

            
603
struct ChargerStandbyPowerMeasurementsView: View {
604
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
605
    @State private var selectedMeasurementIDs = Set<UUID>()
Bogdan Timofte authored a month ago
606
    @State private var editMode: EditMode = .inactive
Bogdan Timofte authored a month ago
607

            
608
    let chargerID: UUID
609

            
610
    var body: some View {
611
        Group {
612
            if let charger = appData.chargedDeviceSummary(id: chargerID) {
Bogdan Timofte authored a month ago
613
                measurementsList(for: charger)
Bogdan Timofte authored a month ago
614
            } else {
615
                Text("This charger is no longer available.")
616
                    .foregroundColor(.secondary)
617
                    .navigationTitle("Saved Measurements")
618
            }
619
        }
620
    }
621

            
Bogdan Timofte authored a month ago
622
    @ViewBuilder
623
    private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
624
        let content = List(selection: $selectedMeasurementIDs) {
625
            if charger.standbyPowerMeasurements.isEmpty {
626
                Text("No standby measurements saved yet.")
627
                    .foregroundColor(.secondary)
628
            } else {
629
                ForEach(charger.standbyPowerMeasurements) { measurement in
630
                    NavigationLink(
631
                        destination: ChargerStandbyPowerMeasurementDetailView(
632
                            chargerID: charger.id,
633
                            measurementID: measurement.id
634
                        )
635
                    ) {
636
                        VStack(alignment: .leading, spacing: 6) {
637
                            HStack {
638
                                Text(measurement.endedAt.format())
639
                                    .font(.subheadline.weight(.semibold))
640
                                Spacer()
641
                                Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
642
                                    .font(.subheadline.weight(.bold))
643
                                    .monospacedDigit()
644
                            }
645

            
646
                            Text(
647
                                "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
648
                            )
649
                            .font(.caption)
650
                            .foregroundColor(.secondary)
651
                        }
652
                        .frame(maxWidth: .infinity, alignment: .leading)
653
                    }
654
                    .tag(measurement.id)
655
                }
656
                .onDelete { offsets in
657
                    let measurements = charger.standbyPowerMeasurements
658
                    for index in offsets {
659
                        guard measurements.indices.contains(index) else { continue }
660
                        let measurement = measurements[index]
661
                        _ = appData.deleteChargerStandbyMeasurement(
662
                            id: measurement.id,
663
                            chargerID: charger.id
664
                        )
665
                    }
666
                }
667
            }
668
        }
Bogdan Timofte authored a month ago
669
        .environment(\.editMode, $editMode)
Bogdan Timofte authored a month ago
670
        .navigationTitle("Saved Measurements")
671
        .toolbar {
672
            ToolbarItem(placement: .primaryAction) {
Bogdan Timofte authored a month ago
673
                Button(editMode.isEditing ? "Done" : "Select") {
674
                    if editMode.isEditing {
675
                        editMode = .inactive
676
                        selectedMeasurementIDs.removeAll()
677
                    } else {
678
                        editMode = .active
679
                    }
680
                }
Bogdan Timofte authored a month ago
681
            }
682
        }
683

            
684
        if selectedMeasurementIDs.isEmpty {
685
            content
686
        } else {
687
            content.toolbar {
688
                ToolbarItem(placement: .destructiveAction) {
689
                    Button(role: .destructive) {
690
                        deleteMeasurements(
691
                            ids: selectedMeasurementIDs,
692
                            for: charger.id
693
                        )
694
                    } label: {
695
                        Image(systemName: "trash")
696
                    }
697
                }
698
            }
699
        }
700
    }
701

            
Bogdan Timofte authored a month ago
702
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
703
        if wattHours >= 1000 {
704
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
705
        }
706
        return "\(wattHours.format(decimalDigits: 2)) Wh"
707
    }
708

            
709
    private func formattedDuration(_ duration: TimeInterval) -> String {
710
        let formatter = DateComponentsFormatter()
711
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
712
        formatter.unitsStyle = .abbreviated
713
        formatter.zeroFormattingBehavior = .pad
714
        return formatter.string(from: max(duration, 0)) ?? "0s"
715
    }
Bogdan Timofte authored a month ago
716

            
717
    private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
718
        for id in ids {
719
            _ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID)
720
        }
721
        selectedMeasurementIDs.removeAll()
Bogdan Timofte authored a month ago
722
        editMode = .inactive
Bogdan Timofte authored a month ago
723
    }
Bogdan Timofte authored a month ago
724
}
725

            
726
struct ChargerStandbyPowerMeasurementDetailView: View {
727
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
728
    @Environment(\.dismiss) private var dismiss
729

            
730
    @State private var deleteConfirmationVisibility = false
Bogdan Timofte authored a month ago
731

            
732
    let chargerID: UUID
733
    let measurementID: UUID
734

            
735
    var body: some View {
736
        Group {
737
            if let charger = appData.chargedDeviceSummary(id: chargerID),
738
               let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
739
                ScrollView {
740
                    VStack(spacing: 18) {
741
                        MeterInfoCardView(title: charger.name, tint: .orange) {
742
                            MeterInfoRowView(label: "Saved", value: measurement.endedAt.format())
743
                            MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
744
                            MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)")
745
                            MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration))
746
                        }
747

            
748
                        ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement)
749
                    }
750
                    .padding()
751
                }
752
                .background(
753
                    LinearGradient(
754
                        colors: [.orange.opacity(0.16), Color.clear],
755
                        startPoint: .topLeading,
756
                        endPoint: .bottomTrailing
Bogdan Timofte authored a month ago
757
                )
758
                .ignoresSafeArea()
Bogdan Timofte authored a month ago
759
                )
760
                .navigationTitle("Measurement")
Bogdan Timofte authored a month ago
761
                .toolbar {
762
                    ToolbarItem(placement: .primaryAction) {
763
                        Button(role: .destructive) {
764
                            deleteConfirmationVisibility = true
765
                        } label: {
766
                            Label("Delete Measurement", systemImage: "trash")
767
                        }
768
                    }
769
                }
770
                .confirmationDialog(
771
                    "Delete this measurement?",
772
                    isPresented: $deleteConfirmationVisibility,
773
                    titleVisibility: .visible
774
                ) {
775
                    Button("Delete", role: .destructive) {
776
                        let didDelete = appData.deleteChargerStandbyMeasurement(
777
                            id: measurement.id,
778
                            chargerID: charger.id
779
                        )
780
                        if didDelete {
781
                            dismiss()
782
                        }
783
                    }
784
                    Button("Cancel", role: .cancel) {}
785
                } message: {
786
                    Text("This removes the saved standby measurement from the charger history and iCloud sync.")
787
                }
Bogdan Timofte authored a month ago
788
            } else {
789
                Text("This measurement is no longer available.")
790
                    .foregroundColor(.secondary)
791
                    .navigationTitle("Measurement")
792
            }
793
        }
794
    }
795

            
796
    private func formattedDuration(_ duration: TimeInterval) -> String {
797
        let formatter = DateComponentsFormatter()
798
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
799
        formatter.unitsStyle = .abbreviated
800
        formatter.zeroFormattingBehavior = .pad
801
        return formatter.string(from: max(duration, 0)) ?? "0s"
802
    }
803
}
804

            
805
private struct ChargerStandbyPowerMeasurementSnapshotView: View {
806
    let measurement: ChargerStandbyPowerMeasurementSummary
807

            
808
    var body: some View {
809
        VStack(spacing: 18) {
810
            stabilityCard
811
            projectionCard
812
            distributionCard
813
            statisticsCard
814
        }
815
    }
816

            
817
    private var stabilityCard: some View {
818
        VStack(alignment: .leading, spacing: 10) {
819
            HStack {
820
                VStack(alignment: .leading, spacing: 4) {
821
                    Text(measurement.isStable ? "Enough Samples" : "Still Settling")
822
                        .font(.headline)
823
                    Text("Saved \(measurement.endedAt.format())")
824
                        .font(.caption)
825
                        .foregroundColor(.secondary)
826
                }
827

            
828
                Spacer()
829

            
830
                Text(measurement.isStable ? "Ready" : "Live")
831
                    .font(.caption.weight(.semibold))
832
                    .padding(.horizontal, 10)
833
                    .padding(.vertical, 6)
834
                    .foregroundColor(measurement.isStable ? .green : .orange)
835
                    .meterCard(
836
                        tint: measurement.isStable ? .green : .orange,
837
                        fillOpacity: 0.10,
838
                        strokeOpacity: 0.16,
839
                        cornerRadius: 999
840
                    )
841
            }
842

            
843
            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
844
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
845
                .monospacedDigit()
846

            
847
            Text(
848
                "Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples."
849
            )
850
            .font(.footnote)
851
            .foregroundColor(.secondary)
852
        }
853
        .frame(maxWidth: .infinity, alignment: .leading)
854
        .padding(18)
855
        .meterCard(
856
            tint: measurement.isStable ? .green : .orange,
857
            fillOpacity: 0.18,
858
            strokeOpacity: 0.24
859
        )
860
    }
861

            
862
    private var projectionCard: some View {
863
        MeterInfoCardView(
864
            title: "Consumption Projection",
865
            infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
866
            tint: .teal
867
        ) {
868
            MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
869
            MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh))
870
            MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh))
871
            MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh))
872
            MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh))
873
        }
874
    }
875

            
876
    private var distributionCard: some View {
877
        MeterInfoCardView(
878
            title: "Distribution",
879
            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
880
            tint: .orange
881
        ) {
882
            StandbyPowerHistogramView(
883
                histogram: measurement.histogram,
884
                averagePowerWatts: measurement.averagePowerWatts,
885
                standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
886
                tint: .orange
887
            )
888
            .frame(height: 220)
889

            
890
            if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
Bogdan Timofte authored a month ago
891
                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
Bogdan Timofte authored a month ago
892
                HStack {
893
                    Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
894
                    Spacer()
Bogdan Timofte authored a month ago
895
                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
896
                    Spacer()
Bogdan Timofte authored a month ago
897
                    Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
898
                }
899
                .font(.caption)
900
                .foregroundColor(.secondary)
901
                .monospacedDigit()
902
            }
903
        }
904
    }
905

            
906
    private var statisticsCard: some View {
907
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
908
            MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W")
909
            MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W")
910
            MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W")
911
            MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W")
912
            MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%")
913
            MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A")
914
            MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V")
915
            MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady")
916
        }
917
    }
918

            
919
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
920
        if wattHours >= 1000 {
921
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
922
        }
923
        return "\(wattHours.format(decimalDigits: 2)) Wh"
924
    }
925
}