USB-Meter / USB Meter / Views / Chargers / ChargerStandbyPowerWizardView.swift
Newer Older
984 lines | 40.691kb
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
Bogdan Timofte authored a month ago
9
import UniformTypeIdentifiers
Bogdan Timofte authored a month ago
10

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

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

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

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

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

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

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

            
88
    private var activeSession: ChargerStandbyPowerMonitorSession? {
89
        let candidateMACAddresses = [
90
            selectedMeterMACAddress ?? "",
Bogdan Timofte authored a month ago
91
            preferredMeterMACAddress ?? ""
Bogdan Timofte authored a month ago
92
        ]
93
        .filter { $0.isEmpty == false }
94

            
95
        for macAddress in candidateMACAddresses {
96
            if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
97
                return session
98
            }
99
        }
100

            
101
        for meterSummary in liveMeterSummaries {
102
            if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
103
                return session
104
            }
105
        }
106

            
107
        return nil
108
    }
109

            
110
    private var suggestedMeterSummary: AppData.MeterSummary? {
111
        if let preferredMeterMACAddress {
112
            return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
113
        }
114

            
115
        return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil
116
    }
117

            
118
    private var selectedMeterSummary: AppData.MeterSummary? {
119
        if let activeSession {
120
            return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
121
        }
122

            
123
        guard let selectedMeterMACAddress else {
124
            return nil
125
        }
126

            
127
        return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
128
    }
129

            
130
    private var selectedMeter: Meter? {
131
        selectedMeterSummary?.meter
132
    }
133

            
134
    private var isChargerSelectionLocked: Bool {
135
        locksChargerSelection || preferredChargerID != nil
136
    }
137

            
138
    private var meterSelectionBinding: Binding<String?> {
139
        Binding(
140
            get: { selectedMeterMACAddress },
141
            set: { selectedMeterMACAddress = $0 }
142
        )
143
    }
144

            
145
    private var selectedCharger: ChargedDeviceSummary? {
146
        if let activeSession {
147
            return appData.chargedDeviceSummary(id: activeSession.chargerID)
148
        }
149

            
150
        guard let selectedChargerID else {
151
            return nil
152
        }
153

            
154
        return appData.chargedDeviceSummary(id: selectedChargerID)
155
    }
156

            
157
    private var chargerSelectionBinding: Binding<UUID?> {
158
        Binding(
159
            get: { selectedChargerID },
160
            set: { selectedChargerID = $0 }
161
        )
162
    }
163

            
164
    private var preferredChargerSummary: ChargedDeviceSummary? {
165
        guard let preferredChargerID else {
166
            return nil
167
        }
168
        return appData.chargedDeviceSummary(id: preferredChargerID)
169
    }
170

            
171
    private var navigationTitleText: String {
172
        "New Standby Consumption Measurement"
173
    }
174

            
175
    private var wizardCardTitle: String {
176
        if let selectedMeterSummary {
177
            return selectedMeterSummary.displayName
178
        }
179

            
180
        if let suggestedMeterSummary {
181
            return suggestedMeterSummary.displayName
182
        }
183

            
184
        return "Use Meter"
185
    }
186

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

            
209
                        if availableChargers.isEmpty {
210
                            Text("No charger available yet. Open the charger library to create one first.")
211
                                .font(.caption)
212
                                .foregroundColor(.secondary)
213
                        } else {
214
                            Picker("Charger", selection: chargerSelectionBinding) {
215
                                Text("Select Charger").tag(Optional<UUID>.none)
216
                                ForEach(availableChargers) { charger in
217
                                    Text(charger.name).tag(Optional(charger.id))
218
                                }
219
                            }
220
                            .pickerStyle(.menu)
221
                        }
222
                    }
223

            
224
                    HStack(spacing: 8) {
225
                        Text("Use Meter")
226
                            .font(.subheadline.weight(.semibold))
227
                        ContextInfoButton(
228
                            title: "Use Meter",
229
                            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."
230
                        )
231
                    }
232

            
233
                    Picker("Use Meter", selection: meterSelectionBinding) {
234
                        Text("Select Meter").tag(Optional<String>.none)
235
                        ForEach(liveMeterSummaries) { meterSummary in
236
                            Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress))
237
                        }
238
                    }
239
                    .pickerStyle(.menu)
240
                    .disabled(activeSession != nil)
241

            
242
                    if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
243
                        Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
244
                            .font(.caption)
245
                            .foregroundColor(.secondary)
246
                    }
247

            
248
                    HStack(spacing: 12) {
249
                        if isChargerSelectionLocked == false {
250
                            Button("Manage Charger Library") {
251
                                chargerLibraryVisibility = true
252
                            }
253
                            .disabled(selectedMeter == nil)
254
                        }
255

            
256
                        Button("Start Measurement") {
257
                            startMeasurement()
258
                        }
259
                        .disabled(selectedCharger == nil || selectedMeter == nil)
260
                    }
261
                    .buttonStyle(.borderedProminent)
262
                }
263
            }
264

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

            
287
    private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
288
        MeterInfoCardView(
289
            title: "Measurement Running",
290
            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.",
291
            tint: .orange
292
        ) {
293
            MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress)
294
            MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger")
295
            MeterInfoRowView(label: "Status", value: session.readinessDescription)
296
            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)")
297

            
298
            HStack(spacing: 12) {
299
                Button("Save Result") {
300
                    _ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true)
301
                }
302
                .disabled(session.hasSamples == false)
303

            
304
                Button("Discard") {
305
                    discardConfirmationVisibility = true
306
                }
307
                .foregroundColor(.red)
308
            }
309
            .buttonStyle(.borderedProminent)
310
        }
311
    }
312

            
313
    private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
314
        VStack(spacing: 18) {
315
            if let statistics = session.statistics {
316
                stabilityCard(
317
                    isStable: statistics.isStable,
318
                    averagePowerWatts: statistics.averagePowerWatts,
319
                    stabilityDeltaWatts: statistics.stabilityDeltaWatts,
320
                    stabilityToleranceWatts: statistics.stabilityToleranceWatts,
321
                    sampleCount: statistics.sampleCount
322
                )
323

            
324
                projectionCard(
325
                    averagePowerWatts: statistics.averagePowerWatts,
326
                    projectedDailyEnergyWh: statistics.projectedDailyEnergyWh,
327
                    projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh,
328
                    projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh,
329
                    projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh
330
                )
331

            
Bogdan Timofte authored a month ago
332
                StandbyPowerDistributionCard(
Bogdan Timofte authored a month ago
333
                    histogram: statistics.histogram,
334
                    averagePowerWatts: statistics.averagePowerWatts,
335
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
336
                    tint: .orange
337
                )
338

            
339
                statisticsCard(
340
                    averagePowerWatts: statistics.averagePowerWatts,
341
                    medianPowerWatts: statistics.medianPowerWatts,
342
                    minimumPowerWatts: statistics.minimumPowerWatts,
343
                    maximumPowerWatts: statistics.maximumPowerWatts,
344
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
345
                    coefficientOfVariation: statistics.coefficientOfVariation,
346
                    averageCurrentAmps: statistics.averageCurrentAmps,
347
                    averageVoltageVolts: statistics.averageVoltageVolts
348
                )
349
            } else {
350
                MeterInfoCardView(title: "Live Stats", tint: .orange) {
351
                    Text("Waiting for the first valid power samples from the meter.")
352
                        .font(.footnote)
353
                        .foregroundColor(.secondary)
354
                }
355
            }
356
        }
357
    }
358

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

            
377
                Spacer()
378

            
379
                Text(isStable ? "Ready" : "Live")
380
                    .font(.caption.weight(.semibold))
381
                    .padding(.horizontal, 10)
382
                    .padding(.vertical, 6)
383
                    .foregroundColor(isStable ? .green : .orange)
384
                    .meterCard(
385
                        tint: isStable ? .green : .orange,
386
                        fillOpacity: 0.10,
387
                        strokeOpacity: 0.16,
388
                        cornerRadius: 999
389
                    )
390
            }
391

            
392
            Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
393
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
394
                .monospacedDigit()
395

            
396
            Text(
397
                "Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples."
398
            )
399
            .font(.footnote)
400
            .foregroundColor(.secondary)
401
        }
402
        .frame(maxWidth: .infinity, alignment: .leading)
403
        .padding(18)
404
        .meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
405
    }
406

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

            
427
    private func statisticsCard(
428
        averagePowerWatts: Double,
429
        medianPowerWatts: Double,
430
        minimumPowerWatts: Double,
431
        maximumPowerWatts: Double,
432
        standardDeviationPowerWatts: Double,
433
        coefficientOfVariation: Double,
434
        averageCurrentAmps: Double,
435
        averageVoltageVolts: Double
436
    ) -> some View {
437
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
438
            MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W")
439
            MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W")
440
            MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W")
441
            MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W")
442
            MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%")
443
            MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A")
444
            MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V")
445
            MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady")
446
        }
447
    }
448

            
449
    private func startMeasurement() {
450
        guard let selectedCharger, let selectedMeter else {
451
            return
452
        }
453

            
454
        _ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter)
455
    }
456

            
457
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
458
        if wattHours >= 1000 {
459
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
460
        }
461
        return "\(wattHours.format(decimalDigits: 2)) Wh"
462
    }
463

            
464
    private func formattedDuration(_ duration: TimeInterval) -> String {
465
        let formatter = DateComponentsFormatter()
466
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
467
        formatter.unitsStyle = .abbreviated
468
        formatter.zeroFormattingBehavior = .pad
469
        return formatter.string(from: max(duration, 0)) ?? "0s"
470
    }
471

            
472
}
473

            
Bogdan Timofte authored a month ago
474
// MARK: - Distribution card with resolution picker and CSV export
475

            
476
private struct StandbyPowerDistributionCard: View {
477
    let histogram: [ChargerStandbyPowerDistributionBin]
478
    let averagePowerWatts: Double
479
    let standardDeviationPowerWatts: Double
480
    let tint: Color
481
    var showExport: Bool = false
482

            
483
    private func resolution(for width: CGFloat) -> HistogramResolution {
484
        if width >= 600 { return .x4 }
485
        if width >= 360 { return .x2 }
486
        return .x1
487
    }
488

            
489
    private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
490
        let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue
491
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor)
492
    }
493

            
494
    private var csvString: String {
495
        var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"]
496
        for bin in histogram {
497
            lines.append(
498
                "\(bin.index + 1),"
499
                + String(format: "%.6f", bin.lowerBoundWatts) + ","
500
                + String(format: "%.6f", bin.upperBoundWatts) + ","
501
                + "\(bin.count),"
502
                + String(format: "%.4f", bin.relativeFrequency * 100)
503
            )
504
        }
505
        return lines.joined(separator: "\n")
506
    }
507

            
508
    var body: some View {
509
        MeterInfoCardView(
510
            title: "Value Spread",
511
            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.",
512
            tint: tint,
513
            trailingActions: {
514
                if showExport {
515
                    if #available(iOS 16, *) {
516
                        ShareLink(
517
                            item: DistributionCSVExport(content: csvString),
518
                            preview: SharePreview("distribution.csv")
519
                        ) {
520
                            Image(systemName: "square.and.arrow.up")
521
                                .font(.subheadline.weight(.medium))
522
                                .foregroundStyle(.secondary)
523
                        }
524
                    } else {
525
                        Button {
526
                            exportCSVLegacy(csvString)
527
                        } label: {
528
                            Image(systemName: "square.and.arrow.up")
529
                                .font(.subheadline.weight(.medium))
530
                                .foregroundStyle(.secondary)
531
                        }
532
                    }
533
                }
534
            }
535
        ) {
536
            GeometryReader { proxy in
537
                let bins = displayedHistogram(width: proxy.size.width)
538
                StandbyPowerHistogramView(
539
                    histogram: bins,
540
                    averagePowerWatts: averagePowerWatts,
541
                    standardDeviationPowerWatts: standardDeviationPowerWatts,
542
                    tint: tint
543
                )
544

            
545
                if let firstBin = bins.first, let lastBin = bins.last {
546
                    let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
547
                    VStack {
548
                        Spacer()
549
                        HStack {
550
                            Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
551
                            Spacer()
552
                            Text("\(midpointWatts.format(decimalDigits: 3)) W")
553
                            Spacer()
554
                            Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
555
                        }
556
                        .font(.caption)
557
                        .foregroundColor(.secondary)
558
                        .monospacedDigit()
559
                    }
560
                }
561
            }
562
            .frame(height: 240)
563
        }
564
    }
565

            
566
    private func exportCSVLegacy(_ csv: String) {
567
        guard let windowScene = UIApplication.shared.connectedScenes
568
            .compactMap({ $0 as? UIWindowScene }).first,
569
              let rootVC = windowScene.windows.first?.rootViewController else { return }
570
        let activityVC = UIActivityViewController(
571
            activityItems: [csv],
572
            applicationActivities: nil
573
        )
574
        rootVC.present(activityVC, animated: true)
575
    }
576
}
577

            
578
@available(iOS 16, *)
579
struct DistributionCSVExport: Transferable {
580
    let content: String
581

            
582
    static var transferRepresentation: some TransferRepresentation {
583
        DataRepresentation(exportedContentType: .commaSeparatedText) { export in
584
            Data(export.content.utf8)
585
        }
586
        .suggestedFileName("distribution")
587
    }
588
}
589

            
590
// MARK: - Histogram bars + Gaussian curve
591

            
Bogdan Timofte authored a month ago
592
private struct StandbyPowerHistogramView: View {
593
    let histogram: [ChargerStandbyPowerDistributionBin]
594
    let averagePowerWatts: Double
595
    let standardDeviationPowerWatts: Double
596
    let tint: Color
597

            
598
    var body: some View {
599
        GeometryReader { proxy in
600
            let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1)
601

            
602
            ZStack {
603
                HStack(alignment: .bottom, spacing: 6) {
604
                    ForEach(histogram) { bin in
605
                        RoundedRectangle(cornerRadius: 8, style: .continuous)
606
                            .fill(tint.opacity(0.24))
607
                            .overlay(
608
                                RoundedRectangle(cornerRadius: 8, style: .continuous)
609
                                    .stroke(tint.opacity(0.22), lineWidth: 1)
610
                            )
611
                            .frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height))
612
                    }
613
                }
614
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
615

            
616
                gaussianCurve(in: proxy.size)
617
                    .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
618

            
619
                meanMarker(in: proxy.size)
620
                    .stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
621
            }
622
        }
623
    }
624

            
625
    private func gaussianCurve(in size: CGSize) -> Path {
626
        guard histogram.count > 1,
627
              standardDeviationPowerWatts > 0,
628
              let firstBin = histogram.first,
629
              let lastBin = histogram.last else {
630
            return Path()
631
        }
632

            
633
        let minimum = firstBin.lowerBoundWatts
634
        let maximum = lastBin.upperBoundWatts
635
        let span = max(maximum - minimum, 0.000_001)
636
        let sampleCount = 48
637
        let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi))
638

            
639
        return Path { path in
640
            for index in 0...sampleCount {
641
                let progress = Double(index) / Double(sampleCount)
642
                let value = minimum + (span * progress)
643
                let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts
644
                let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi))
645
                let normalizedHeight = density / peakDensity
646

            
647
                let x = progress * size.width
648
                let y = size.height - (normalizedHeight * (Double(size.height) * 0.92))
649
                let point = CGPoint(x: x, y: y)
650

            
651
                if index == 0 {
652
                    path.move(to: point)
653
                } else {
654
                    path.addLine(to: point)
655
                }
656
            }
657
        }
658
    }
659

            
660
    private func meanMarker(in size: CGSize) -> Path {
661
        guard let firstBin = histogram.first, let lastBin = histogram.last else {
662
            return Path()
663
        }
664

            
665
        let minimum = firstBin.lowerBoundWatts
666
        let maximum = lastBin.upperBoundWatts
667
        let span = max(maximum - minimum, 0.000_001)
668
        let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1)
669
        let x = normalizedX * size.width
670

            
671
        return Path { path in
672
            path.move(to: CGPoint(x: x, y: 0))
673
            path.addLine(to: CGPoint(x: x, y: size.height))
674
        }
675
    }
676
}
677

            
678
struct ChargerStandbyPowerMeasurementsView: View {
679
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
680
    @State private var selectedMeasurementIDs = Set<UUID>()
Bogdan Timofte authored a month ago
681
    @State private var editMode: EditMode = .inactive
Bogdan Timofte authored a month ago
682

            
683
    let chargerID: UUID
684

            
685
    var body: some View {
686
        Group {
687
            if let charger = appData.chargedDeviceSummary(id: chargerID) {
Bogdan Timofte authored a month ago
688
                measurementsList(for: charger)
Bogdan Timofte authored a month ago
689
            } else {
690
                Text("This charger is no longer available.")
691
                    .foregroundColor(.secondary)
692
                    .navigationTitle("Saved Measurements")
Bogdan Timofte authored a month ago
693
                    .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
694
            }
695
        }
696
    }
697

            
Bogdan Timofte authored a month ago
698
    @ViewBuilder
699
    private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
700
        let content = List(selection: $selectedMeasurementIDs) {
701
            if charger.standbyPowerMeasurements.isEmpty {
702
                Text("No standby measurements saved yet.")
703
                    .foregroundColor(.secondary)
704
            } else {
705
                ForEach(charger.standbyPowerMeasurements) { measurement in
706
                    NavigationLink(
707
                        destination: ChargerStandbyPowerMeasurementDetailView(
708
                            chargerID: charger.id,
709
                            measurementID: measurement.id
710
                        )
711
                    ) {
712
                        VStack(alignment: .leading, spacing: 6) {
713
                            HStack {
714
                                Text(measurement.endedAt.format())
715
                                    .font(.subheadline.weight(.semibold))
716
                                Spacer()
717
                                Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
718
                                    .font(.subheadline.weight(.bold))
719
                                    .monospacedDigit()
720
                            }
721

            
722
                            Text(
723
                                "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
724
                            )
725
                            .font(.caption)
726
                            .foregroundColor(.secondary)
727
                        }
728
                        .frame(maxWidth: .infinity, alignment: .leading)
729
                    }
730
                    .tag(measurement.id)
731
                }
732
                .onDelete { offsets in
733
                    let measurements = charger.standbyPowerMeasurements
734
                    for index in offsets {
735
                        guard measurements.indices.contains(index) else { continue }
736
                        let measurement = measurements[index]
737
                        _ = appData.deleteChargerStandbyMeasurement(
738
                            id: measurement.id,
739
                            chargerID: charger.id
740
                        )
741
                    }
742
                }
743
            }
744
        }
Bogdan Timofte authored a month ago
745
        .environment(\.editMode, $editMode)
Bogdan Timofte authored a month ago
746
        .navigationTitle("Saved Measurements")
Bogdan Timofte authored a month ago
747
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
748
        .toolbar {
749
            ToolbarItem(placement: .primaryAction) {
Bogdan Timofte authored a month ago
750
                Button(editMode.isEditing ? "Done" : "Select") {
751
                    if editMode.isEditing {
752
                        editMode = .inactive
753
                        selectedMeasurementIDs.removeAll()
754
                    } else {
755
                        editMode = .active
756
                    }
757
                }
Bogdan Timofte authored a month ago
758
            }
759
        }
760

            
761
        if selectedMeasurementIDs.isEmpty {
762
            content
763
        } else {
764
            content.toolbar {
765
                ToolbarItem(placement: .destructiveAction) {
766
                    Button(role: .destructive) {
767
                        deleteMeasurements(
768
                            ids: selectedMeasurementIDs,
769
                            for: charger.id
770
                        )
771
                    } label: {
772
                        Image(systemName: "trash")
773
                    }
774
                }
775
            }
776
        }
777
    }
778

            
Bogdan Timofte authored a month ago
779
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
780
        if wattHours >= 1000 {
781
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
782
        }
783
        return "\(wattHours.format(decimalDigits: 2)) Wh"
784
    }
785

            
786
    private func formattedDuration(_ duration: TimeInterval) -> String {
787
        let formatter = DateComponentsFormatter()
788
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
789
        formatter.unitsStyle = .abbreviated
790
        formatter.zeroFormattingBehavior = .pad
791
        return formatter.string(from: max(duration, 0)) ?? "0s"
792
    }
Bogdan Timofte authored a month ago
793

            
794
    private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
795
        for id in ids {
796
            _ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID)
797
        }
798
        selectedMeasurementIDs.removeAll()
Bogdan Timofte authored a month ago
799
        editMode = .inactive
Bogdan Timofte authored a month ago
800
    }
Bogdan Timofte authored a month ago
801
}
802

            
803
struct ChargerStandbyPowerMeasurementDetailView: View {
804
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
805
    @Environment(\.dismiss) private var dismiss
806

            
807
    @State private var deleteConfirmationVisibility = false
Bogdan Timofte authored a month ago
808

            
809
    let chargerID: UUID
810
    let measurementID: UUID
811

            
812
    var body: some View {
813
        Group {
814
            if let charger = appData.chargedDeviceSummary(id: chargerID),
815
               let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
816
                ScrollView {
817
                    VStack(spacing: 18) {
818
                        MeterInfoCardView(title: charger.name, tint: .orange) {
819
                            MeterInfoRowView(label: "Saved", value: measurement.endedAt.format())
820
                            MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
821
                            MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)")
822
                            MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration))
823
                        }
824

            
825
                        ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement)
826
                    }
827
                    .padding()
828
                }
829
                .background(
830
                    LinearGradient(
831
                        colors: [.orange.opacity(0.16), Color.clear],
832
                        startPoint: .topLeading,
833
                        endPoint: .bottomTrailing
Bogdan Timofte authored a month ago
834
                )
835
                .ignoresSafeArea()
Bogdan Timofte authored a month ago
836
                )
837
                .navigationTitle("Measurement")
Bogdan Timofte authored a month ago
838
                .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
839
                .toolbar {
840
                    ToolbarItem(placement: .primaryAction) {
841
                        Button(role: .destructive) {
842
                            deleteConfirmationVisibility = true
843
                        } label: {
844
                            Label("Delete Measurement", systemImage: "trash")
845
                        }
846
                    }
847
                }
848
                .confirmationDialog(
849
                    "Delete this measurement?",
850
                    isPresented: $deleteConfirmationVisibility,
851
                    titleVisibility: .visible
852
                ) {
853
                    Button("Delete", role: .destructive) {
854
                        let didDelete = appData.deleteChargerStandbyMeasurement(
855
                            id: measurement.id,
856
                            chargerID: charger.id
857
                        )
858
                        if didDelete {
859
                            dismiss()
860
                        }
861
                    }
862
                    Button("Cancel", role: .cancel) {}
863
                } message: {
864
                    Text("This removes the saved standby measurement from the charger history and iCloud sync.")
865
                }
Bogdan Timofte authored a month ago
866
            } else {
867
                Text("This measurement is no longer available.")
868
                    .foregroundColor(.secondary)
869
                    .navigationTitle("Measurement")
Bogdan Timofte authored a month ago
870
                    .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
871
            }
872
        }
873
    }
874

            
875
    private func formattedDuration(_ duration: TimeInterval) -> String {
876
        let formatter = DateComponentsFormatter()
877
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
878
        formatter.unitsStyle = .abbreviated
879
        formatter.zeroFormattingBehavior = .pad
880
        return formatter.string(from: max(duration, 0)) ?? "0s"
881
    }
882
}
883

            
884
private struct ChargerStandbyPowerMeasurementSnapshotView: View {
885
    let measurement: ChargerStandbyPowerMeasurementSummary
886

            
887
    var body: some View {
888
        VStack(spacing: 18) {
889
            stabilityCard
890
            projectionCard
891
            distributionCard
892
            statisticsCard
893
        }
894
    }
895

            
896
    private var stabilityCard: some View {
897
        VStack(alignment: .leading, spacing: 10) {
898
            HStack {
899
                VStack(alignment: .leading, spacing: 4) {
900
                    Text(measurement.isStable ? "Enough Samples" : "Still Settling")
901
                        .font(.headline)
902
                    Text("Saved \(measurement.endedAt.format())")
903
                        .font(.caption)
904
                        .foregroundColor(.secondary)
905
                }
906

            
907
                Spacer()
908

            
909
                Text(measurement.isStable ? "Ready" : "Live")
910
                    .font(.caption.weight(.semibold))
911
                    .padding(.horizontal, 10)
912
                    .padding(.vertical, 6)
913
                    .foregroundColor(measurement.isStable ? .green : .orange)
914
                    .meterCard(
915
                        tint: measurement.isStable ? .green : .orange,
916
                        fillOpacity: 0.10,
917
                        strokeOpacity: 0.16,
918
                        cornerRadius: 999
919
                    )
920
            }
921

            
922
            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
923
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
924
                .monospacedDigit()
925

            
926
            Text(
927
                "Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples."
928
            )
929
            .font(.footnote)
930
            .foregroundColor(.secondary)
931
        }
932
        .frame(maxWidth: .infinity, alignment: .leading)
933
        .padding(18)
934
        .meterCard(
935
            tint: measurement.isStable ? .green : .orange,
936
            fillOpacity: 0.18,
937
            strokeOpacity: 0.24
938
        )
939
    }
940

            
941
    private var projectionCard: some View {
942
        MeterInfoCardView(
943
            title: "Consumption Projection",
944
            infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
945
            tint: .teal
946
        ) {
947
            MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
948
            MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh))
949
            MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh))
950
            MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh))
951
            MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh))
952
        }
953
    }
954

            
955
    private var distributionCard: some View {
Bogdan Timofte authored a month ago
956
        StandbyPowerDistributionCard(
957
            histogram: measurement.storedHistogram,
958
            averagePowerWatts: measurement.averagePowerWatts,
959
            standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
960
            tint: .orange,
961
            showExport: true
962
        )
Bogdan Timofte authored a month ago
963
    }
964

            
965
    private var statisticsCard: some View {
966
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
967
            MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W")
968
            MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W")
969
            MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W")
970
            MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W")
971
            MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%")
972
            MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A")
973
            MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V")
974
            MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady")
975
        }
976
    }
977

            
978
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
979
        if wattHours >= 1000 {
980
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
981
        }
982
        return "\(wattHours.format(decimalDigits: 2)) Wh"
983
    }
984
}