Newer Older
957 lines | 39.211kb
Bogdan Timofte authored 2 months ago
1
//
Bogdan Timofte authored 2 months ago
2
//  MeterLiveContentView.swift
Bogdan Timofte authored 2 months ago
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 09/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10

            
Bogdan Timofte authored 2 months ago
11
struct MeterLiveContentView: View {
Bogdan Timofte authored a month ago
12
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored 2 months ago
13
    @EnvironmentObject private var meter: Meter
Bogdan Timofte authored 2 months ago
14
    @State private var powerAverageSheetVisibility = false
Bogdan Timofte authored a month ago
15
    @State private var energyProjectionSheetVisibility = false
Bogdan Timofte authored 2 months ago
16
    @State private var rssiHistorySheetVisibility = false
Bogdan Timofte authored 2 months ago
17
    var compactLayout: Bool = false
18
    var availableSize: CGSize? = nil
Bogdan Timofte authored 2 months ago
19

            
20
    var body: some View {
Bogdan Timofte authored 2 months ago
21
        VStack(alignment: .leading, spacing: 16) {
Bogdan Timofte authored 2 months ago
22
            HStack {
Bogdan Timofte authored 2 months ago
23
                Text("Live Data")
24
                    .font(.headline)
25
                Spacer()
26
                statusBadge
27
            }
28

            
Bogdan Timofte authored 2 months ago
29
            MeterInfoCardView(title: "Detected Meter", tint: .indigo) {
30
                MeterInfoRowView(label: "Name", value: meter.name.isEmpty ? "Meter" : meter.name)
31
                MeterInfoRowView(label: "Model", value: meter.deviceModelSummary)
32
                MeterInfoRowView(label: "Advertised Model", value: meter.modelString)
33
                MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description)
34
                MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
35
                MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
36
            }
Bogdan Timofte authored 2 months ago
37
            .frame(maxWidth: .infinity, alignment: .leading)
Bogdan Timofte authored 2 months ago
38

            
Bogdan Timofte authored a month ago
39
            NavigationLink(
40
                destination: ChargerStandbyPowerWizardView(
41
                    preferredMeterMACAddress: meter.btSerial.macAddress.description
42
                )
43
            ) {
44
                standbyWizardCard
45
            }
46
            .buttonStyle(.plain)
47

            
Bogdan Timofte authored 2 months ago
48
            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
Bogdan Timofte authored 2 months ago
49
                if shouldShowVoltageCard {
50
                    liveMetricCard(
51
                        title: "Voltage",
52
                        symbol: "bolt.fill",
53
                        color: .green,
54
                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
55
                        range: metricRange(
56
                            min: meter.measurements.voltage.context.minValue,
57
                            max: meter.measurements.voltage.context.maxValue,
58
                            unit: "V"
59
                        )
Bogdan Timofte authored 2 months ago
60
                    )
Bogdan Timofte authored 2 months ago
61
                }
Bogdan Timofte authored 2 months ago
62

            
Bogdan Timofte authored 2 months ago
63
                if shouldShowCurrentCard {
64
                    liveMetricCard(
65
                        title: "Current",
66
                        symbol: "waveform.path.ecg",
67
                        color: .blue,
68
                        value: "\(meter.current.format(decimalDigits: 3)) A",
69
                        range: metricRange(
70
                            min: meter.measurements.current.context.minValue,
71
                            max: meter.measurements.current.context.maxValue,
72
                            unit: "A"
73
                        )
Bogdan Timofte authored 2 months ago
74
                    )
Bogdan Timofte authored 2 months ago
75
                }
Bogdan Timofte authored 2 months ago
76

            
Bogdan Timofte authored 2 months ago
77
                if shouldShowPowerCard {
78
                    liveMetricCard(
79
                        title: "Power",
80
                        symbol: "flame.fill",
81
                        color: .pink,
82
                        value: "\(meter.power.format(decimalDigits: 3)) W",
83
                        range: metricRange(
84
                            min: meter.measurements.power.context.minValue,
85
                            max: meter.measurements.power.context.maxValue,
86
                            unit: "W"
Bogdan Timofte authored 2 months ago
87
                        ),
88
                        action: {
89
                            powerAverageSheetVisibility = true
90
                        }
Bogdan Timofte authored 2 months ago
91
                    )
Bogdan Timofte authored 2 months ago
92
                }
Bogdan Timofte authored 2 months ago
93

            
Bogdan Timofte authored 2 months ago
94
                if shouldShowEnergyCard {
95
                    liveMetricCard(
Bogdan Timofte authored a month ago
96
                        title: "Accumulated Energy",
Bogdan Timofte authored 2 months ago
97
                        symbol: "battery.100.bolt",
98
                        color: .teal,
99
                        value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
Bogdan Timofte authored a month ago
100
                        detailText: "Tap for monthly and yearly projections",
101
                        action: {
102
                            energyProjectionSheetVisibility = true
103
                        }
Bogdan Timofte authored 2 months ago
104
                    )
105
                }
106

            
Bogdan Timofte authored 2 months ago
107
                if shouldShowTemperatureCard {
108
                    liveMetricCard(
109
                        title: "Temperature",
110
                        symbol: "thermometer.medium",
111
                        color: .orange,
112
                        value: meter.primaryTemperatureDescription,
Bogdan Timofte authored 2 months ago
113
                        range: temperatureRange(
114
                            min: meter.measurements.temperature.context.minValue,
115
                            max: meter.measurements.temperature.context.maxValue
116
                        )
Bogdan Timofte authored 2 months ago
117
                    )
118
                }
Bogdan Timofte authored 2 months ago
119

            
Bogdan Timofte authored 2 months ago
120
                if shouldShowLoadCard {
121
                    liveMetricCard(
122
                        title: "Load",
123
                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
124
                        color: .yellow,
125
                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
126
                        detailText: "Measured resistance"
127
                    )
128
                }
Bogdan Timofte authored 2 months ago
129

            
Bogdan Timofte authored 2 months ago
130
                liveMetricCard(
131
                    title: "RSSI",
132
                    symbol: "dot.radiowaves.left.and.right",
133
                    color: .mint,
Bogdan Timofte authored 2 months ago
134
                    value: "\(meter.btSerial.averageRSSI) dBm",
Bogdan Timofte authored 2 months ago
135
                    range: metricRange(
136
                        min: meter.measurements.rssi.context.minValue,
137
                        max: meter.measurements.rssi.context.maxValue,
138
                        unit: "dBm",
139
                        decimalDigits: 0
Bogdan Timofte authored 2 months ago
140
                    ),
Bogdan Timofte authored 2 months ago
141
                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
142
                    action: {
143
                        rssiHistorySheetVisibility = true
144
                    }
Bogdan Timofte authored 2 months ago
145
                )
Bogdan Timofte authored 2 months ago
146

            
Bogdan Timofte authored 2 months ago
147
                if meter.supportsChargerDetection && hasLiveMetrics {
Bogdan Timofte authored 2 months ago
148
                    liveMetricCard(
149
                        title: "Detected Charger",
150
                        symbol: "powerplug.fill",
151
                        color: .indigo,
152
                        value: meter.chargerTypeDescription,
153
                        detailText: "Source handshake",
154
                        valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
155
                        valueLineLimit: 2,
156
                        valueMonospacedDigits: false,
157
                        valueMinimumScaleFactor: 0.72
158
                    )
159
                }
Bogdan Timofte authored 2 months ago
160
            }
161
        }
Bogdan Timofte authored 2 months ago
162
        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
163
        .sheet(isPresented: $powerAverageSheetVisibility) {
164
            PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
165
                .environmentObject(meter.measurements)
166
        }
Bogdan Timofte authored a month ago
167
        .sheet(isPresented: $energyProjectionSheetVisibility) {
168
            EnergyProjectionSheetView(visibility: $energyProjectionSheetVisibility)
169
                .environmentObject(meter.measurements)
170
                .environmentObject(meter)
171
        }
Bogdan Timofte authored 2 months ago
172
        .sheet(isPresented: $rssiHistorySheetVisibility) {
173
            RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
174
                .environmentObject(meter.measurements)
175
        }
Bogdan Timofte authored 2 months ago
176
    }
177

            
Bogdan Timofte authored 2 months ago
178
    private var hasLiveMetrics: Bool {
179
        meter.operationalState == .dataIsAvailable
180
    }
181

            
182
    private var shouldShowVoltageCard: Bool {
183
        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
184
    }
185

            
186
    private var shouldShowCurrentCard: Bool {
187
        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
188
    }
189

            
190
    private var shouldShowPowerCard: Bool {
191
        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
192
    }
193

            
Bogdan Timofte authored 2 months ago
194
    private var shouldShowEnergyCard: Bool {
195
        hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite
196
    }
197

            
Bogdan Timofte authored 2 months ago
198
    private var shouldShowTemperatureCard: Bool {
199
        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
200
    }
201

            
Bogdan Timofte authored 2 months ago
202
    private var liveBufferedEnergyValue: Double {
203
        meter.measurements.energy.samplePoints.last?.value ?? 0
204
    }
205

            
Bogdan Timofte authored 2 months ago
206
    private var shouldShowLoadCard: Bool {
207
        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
208
    }
209

            
Bogdan Timofte authored 2 months ago
210
    private var liveMetricColumns: [GridItem] {
211
        if compactLayout {
Bogdan Timofte authored 2 months ago
212
            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
Bogdan Timofte authored 2 months ago
213
        }
214

            
215
        return [GridItem(.flexible()), GridItem(.flexible())]
Bogdan Timofte authored 2 months ago
216
    }
Bogdan Timofte authored 2 months ago
217

            
218
    private var statusBadge: some View {
219
        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
220
            .font(.caption.weight(.semibold))
221
            .padding(.horizontal, 10)
222
            .padding(.vertical, 6)
223
            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
224
            .meterCard(
225
                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
226
                fillOpacity: 0.12,
227
                strokeOpacity: 0.16,
228
                cornerRadius: 999
229
            )
230
    }
231

            
Bogdan Timofte authored a month ago
232
    private var standbyWizardCard: some View {
233
        MeterInfoCardView(title: "Standby Power", tint: .orange) {
234
            Label("New Measurement", systemImage: "plus.circle.fill")
235
                .font(.subheadline.weight(.semibold))
236
                .foregroundColor(.orange)
237

            
238
            if let session = appData.chargerStandbyMeasurementSession(for: meter.btSerial.macAddress.description) {
239
                Text(
240
                    "Active on \(appData.chargedDeviceSummary(id: session.chargerID)?.name ?? "selected charger") • \(session.readinessDescription)"
241
                )
242
                .font(.caption)
243
                .foregroundColor(.secondary)
244
            } else if let charger = appData.currentChargerSummary(for: meter.btSerial.macAddress.description) {
245
                Text(
246
                    charger.latestStandbyPowerMeasurement.map {
247
                        "\(charger.name) • latest \($0.averagePowerWatts.format(decimalDigits: 3)) W"
248
                    } ?? "\(charger.name) • no saved standby baseline"
249
                )
250
                .font(.caption)
251
                .foregroundColor(.secondary)
252
            } else {
253
                Text("Open the wizard and choose the charger there.")
254
                    .font(.caption)
255
                    .foregroundColor(.secondary)
256
                    .frame(maxWidth: .infinity, alignment: .leading)
257
            }
258
        }
259
    }
260

            
Bogdan Timofte authored 2 months ago
261
    private var showsCompactMetricRange: Bool {
262
        compactLayout && (availableSize?.height ?? 0) >= 380
263
    }
264

            
265
    private var shouldShowMetricRange: Bool {
266
        !compactLayout || showsCompactMetricRange
267
    }
268

            
Bogdan Timofte authored 2 months ago
269
    private func liveMetricCard(
270
        title: String,
Bogdan Timofte authored 2 months ago
271
        symbol: String? = nil,
272
        customSymbol: AnyView? = nil,
Bogdan Timofte authored 2 months ago
273
        color: Color,
274
        value: String,
Bogdan Timofte authored 2 months ago
275
        range: MeterLiveMetricRange? = nil,
Bogdan Timofte authored 2 months ago
276
        detailText: String? = nil,
277
        valueFont: Font? = nil,
278
        valueLineLimit: Int = 1,
279
        valueMonospacedDigits: Bool = true,
Bogdan Timofte authored 2 months ago
280
        valueMinimumScaleFactor: CGFloat = 0.85,
281
        action: (() -> Void)? = nil
Bogdan Timofte authored 2 months ago
282
    ) -> some View {
Bogdan Timofte authored 2 months ago
283
        let cardContent = VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored 2 months ago
284
            HStack(spacing: compactLayout ? 8 : 10) {
Bogdan Timofte authored 2 months ago
285
                Group {
286
                    if let customSymbol {
287
                        customSymbol
288
                    } else if let symbol {
289
                        Image(systemName: symbol)
290
                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
291
                            .foregroundColor(color)
292
                    }
293
                }
Bogdan Timofte authored 2 months ago
294
                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
Bogdan Timofte authored 2 months ago
295
                .background(Circle().fill(color.opacity(0.12)))
Bogdan Timofte authored 2 months ago
296

            
Bogdan Timofte authored 2 months ago
297
                Text(title)
298
                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
299
                    .foregroundColor(.secondary)
300
                    .lineLimit(1)
301

            
302
                Spacer(minLength: 0)
303
            }
Bogdan Timofte authored 2 months ago
304

            
Bogdan Timofte authored 2 months ago
305
            Group {
306
                if valueMonospacedDigits {
307
                    Text(value)
308
                        .monospacedDigit()
309
                } else {
310
                    Text(value)
311
                }
312
            }
313
            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
314
            .lineLimit(valueLineLimit)
315
            .minimumScaleFactor(valueMinimumScaleFactor)
Bogdan Timofte authored 2 months ago
316

            
Bogdan Timofte authored 2 months ago
317
            if shouldShowMetricRange {
318
                if let range {
319
                    metricRangeTable(range)
320
                } else if let detailText, !detailText.isEmpty {
321
                    Text(detailText)
322
                        .font(.caption)
323
                        .foregroundColor(.secondary)
324
                        .lineLimit(2)
325
                }
Bogdan Timofte authored 2 months ago
326
            }
327
        }
Bogdan Timofte authored 2 months ago
328
        .frame(
329
            maxWidth: .infinity,
Bogdan Timofte authored 2 months ago
330
            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
Bogdan Timofte authored 2 months ago
331
            alignment: .leading
332
        )
333
        .padding(compactLayout ? 12 : 16)
Bogdan Timofte authored 2 months ago
334
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
Bogdan Timofte authored 2 months ago
335

            
336
        if let action {
337
            return AnyView(
338
                Button(action: action) {
339
                    cardContent
340
                }
341
                .buttonStyle(.plain)
342
            )
343
        }
344

            
345
        return AnyView(cardContent)
Bogdan Timofte authored 2 months ago
346
    }
347

            
Bogdan Timofte authored 2 months ago
348
    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
Bogdan Timofte authored 2 months ago
349
        VStack(alignment: .leading, spacing: 4) {
350
            HStack(spacing: 12) {
351
                Text(range.minLabel)
352
                Spacer(minLength: 0)
353
                Text(range.maxLabel)
354
            }
355
            .font(.caption2.weight(.semibold))
356
            .foregroundColor(.secondary)
357

            
358
            HStack(spacing: 12) {
359
                Text(range.minValue)
360
                    .monospacedDigit()
361
                Spacer(minLength: 0)
362
                Text(range.maxValue)
363
                    .monospacedDigit()
364
            }
365
            .font(.caption.weight(.medium))
366
            .foregroundColor(.primary)
367
        }
368
    }
369

            
Bogdan Timofte authored 2 months ago
370
    private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
Bogdan Timofte authored 2 months ago
371
        guard min.isFinite, max.isFinite else { return nil }
Bogdan Timofte authored 2 months ago
372

            
Bogdan Timofte authored 2 months ago
373
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 months ago
374
            minLabel: "Min",
375
            maxLabel: "Max",
Bogdan Timofte authored 2 months ago
376
            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
377
            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
Bogdan Timofte authored 2 months ago
378
        )
379
    }
380

            
Bogdan Timofte authored 2 months ago
381
    private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
382
        guard min.isFinite, max.isFinite else { return nil }
383

            
384
        let unitSuffix = temperatureUnitSuffix()
Bogdan Timofte authored 2 months ago
385

            
Bogdan Timofte authored 2 months ago
386
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 months ago
387
            minLabel: "Min",
388
            maxLabel: "Max",
Bogdan Timofte authored 2 months ago
389
            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
390
            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
Bogdan Timofte authored 2 months ago
391
        )
Bogdan Timofte authored 2 months ago
392
    }
Bogdan Timofte authored 2 months ago
393

            
394
    private func meterHistoryText(for date: Date?) -> String {
395
        guard let date else {
396
            return "Never"
397
        }
398
        return date.format(as: "yyyy-MM-dd HH:mm")
399
    }
Bogdan Timofte authored 2 months ago
400

            
401
    private func temperatureUnitSuffix() -> String {
402
        if meter.supportsManualTemperatureUnitSelection {
403
            return "°"
404
        }
405

            
406
        let locale = Locale.autoupdatingCurrent
407
        if #available(iOS 16.0, *) {
408
            switch locale.measurementSystem {
409
            case .us:
410
                return "°F"
411
            default:
412
                return "°C"
413
            }
414
        }
415

            
416
        let regionCode = locale.regionCode ?? ""
417
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
418
        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
419
    }
420
}
421

            
422
private struct PowerAverageSheetView: View {
423
    @EnvironmentObject private var measurements: Measurements
424

            
425
    @Binding var visibility: Bool
426

            
427
    @State private var selectedSampleCount: Int = 20
428

            
429
    var body: some View {
430
        let bufferedSamples = measurements.powerSampleCount()
431

            
432
        NavigationView {
433
            ScrollView {
434
                VStack(alignment: .leading, spacing: 14) {
435
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
436
                        HStack(spacing: 8) {
437
                            Text("Power Average")
438
                                .font(.system(.title3, design: .rounded).weight(.bold))
439
                            ContextInfoButton(
440
                                title: "Power Average",
441
                                message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window."
442
                            )
443
                        }
Bogdan Timofte authored 2 months ago
444
                    }
445
                    .padding(18)
446
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
447

            
448
                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
449
                        if bufferedSamples == 0 {
450
                            Text("No power samples are available yet.")
451
                                .font(.footnote)
452
                                .foregroundColor(.secondary)
453
                        } else {
454
                            VStack(alignment: .leading, spacing: 14) {
455
                                VStack(alignment: .leading, spacing: 8) {
456
                                    Text("Values used")
457
                                        .font(.subheadline.weight(.semibold))
458

            
459
                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
460
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
461
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
462
                                        }
463
                                    }
464
                                    .pickerStyle(.menu)
465
                                }
466

            
467
                                VStack(alignment: .leading, spacing: 6) {
468
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
469
                                        .font(.system(.title2, design: .rounded).weight(.bold))
470
                                        .monospacedDigit()
471

            
472
                                    Text("Buffered samples: \(bufferedSamples)")
473
                                        .font(.caption)
474
                                        .foregroundColor(.secondary)
475
                                }
476
                            }
477
                        }
478
                    }
479

            
Bogdan Timofte authored a month ago
480
                    MeterInfoCardView(
481
                        title: "Buffer Actions",
482
                        infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.",
483
                        tint: .secondary
484
                    ) {
Bogdan Timofte authored 2 months ago
485
                        Button("Reset Buffer") {
486
                            measurements.resetSeries()
487
                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
488
                        }
489
                        .foregroundColor(.red)
490
                    }
491
                }
492
                .padding()
493
            }
494
            .background(
495
                LinearGradient(
496
                    colors: [.pink.opacity(0.14), Color.clear],
497
                    startPoint: .topLeading,
498
                    endPoint: .bottomTrailing
499
                )
500
                .ignoresSafeArea()
501
            )
502
            .navigationBarItems(
503
                leading: Button("Done") { visibility.toggle() }
504
            )
505
            .navigationBarTitle("Power", displayMode: .inline)
506
        }
507
        .navigationViewStyle(StackNavigationViewStyle())
508
        .onAppear {
509
            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
510
        }
511
        .onChange(of: bufferedSamples) { newValue in
512
            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
513
        }
514
    }
515

            
516
    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
517
        guard bufferedSamples > 0 else { return [] }
518

            
519
        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
520
        return (filtered + [bufferedSamples]).sorted()
521
    }
522

            
523
    private func defaultSampleCount(bufferedSamples: Int) -> Int {
524
        guard bufferedSamples > 0 else { return 20 }
525
        return min(20, bufferedSamples)
526
    }
527

            
528
    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
529
        Binding(
530
            get: {
531
                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
532
                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
533
                if availableOptions.contains(selectedSampleCount) {
534
                    return selectedSampleCount
535
                }
536
                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
537
            },
538
            set: { newValue in
539
                selectedSampleCount = newValue
540
            }
541
        )
542
    }
543

            
544
    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
545
        if option == bufferedSamples {
546
            return "All (\(option))"
547
        }
548
        return "\(option) values"
549
    }
550

            
551
    private func averagePowerLabel(bufferedSamples: Int) -> String {
552
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
553
            return "No data"
554
        }
555

            
556
        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
557
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
558
    }
559
}
560

            
561
private struct RSSIHistorySheetView: View {
562
    @EnvironmentObject private var measurements: Measurements
563

            
564
    @Binding var visibility: Bool
565

            
566
    private let xLabels: Int = 4
567
    private let yLabels: Int = 4
568

            
569
    var body: some View {
570
        let points = measurements.rssi.points
571
        let samplePoints = measurements.rssi.samplePoints
572
        let chartContext = buildChartContext(for: samplePoints)
573

            
574
        NavigationView {
575
            ScrollView {
576
                VStack(alignment: .leading, spacing: 14) {
577
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
578
                        HStack(spacing: 8) {
579
                            Text("RSSI History")
580
                                .font(.system(.title3, design: .rounded).weight(.bold))
581
                            ContextInfoButton(
582
                                title: "RSSI History",
583
                                message: "Signal strength captured over time while the meter stays connected."
584
                            )
585
                        }
Bogdan Timofte authored 2 months ago
586
                    }
587
                    .padding(18)
588
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
589

            
590
                    if samplePoints.isEmpty {
591
                        Text("No RSSI samples have been captured yet.")
592
                            .font(.footnote)
593
                            .foregroundColor(.secondary)
594
                            .padding(18)
595
                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
596
                    } else {
597
                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
598
                            VStack(alignment: .leading, spacing: 12) {
599
                                HStack(spacing: 12) {
600
                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
601
                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
602
                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
603
                                }
604

            
605
                                HStack(spacing: 8) {
606
                                    rssiYAxisView(context: chartContext)
607
                                        .frame(width: 52, height: 220)
608

            
609
                                    ZStack {
610
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
611
                                            .fill(Color.primary.opacity(0.05))
612

            
613
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
614
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
615

            
616
                                        rssiHorizontalGuides(context: chartContext)
617
                                        rssiVerticalGuides(context: chartContext)
618
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
619
                                            .opacity(0.82)
620
                                    }
621
                                    .frame(maxWidth: .infinity)
622
                                    .frame(height: 220)
623
                                }
624

            
625
                                rssiXAxisLabelsView(context: chartContext)
626
                                    .frame(height: 28)
627
                            }
628
                        }
629
                    }
630
                }
631
                .padding()
632
            }
633
            .background(
634
                LinearGradient(
635
                    colors: [.mint.opacity(0.14), Color.clear],
636
                    startPoint: .topLeading,
637
                    endPoint: .bottomTrailing
638
                )
639
                .ignoresSafeArea()
640
            )
641
            .navigationBarItems(
642
                leading: Button("Done") { visibility.toggle() }
643
            )
644
            .navigationBarTitle("RSSI", displayMode: .inline)
645
        }
646
        .navigationViewStyle(StackNavigationViewStyle())
647
    }
648

            
649
    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
650
        let context = ChartContext()
651
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
652
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
653
        let minimumValue = samplePoints.map(\.value).min() ?? -100
654
        let maximumValue = samplePoints.map(\.value).max() ?? -40
655
        let padding = max((maximumValue - minimumValue) * 0.12, 4)
656

            
657
        context.setBounds(
658
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
659
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
660
            yMin: CGFloat(minimumValue - padding),
661
            yMax: CGFloat(maximumValue + padding)
662
        )
663
        return context
664
    }
665

            
666
    private func signalSummaryChip(title: String, value: String) -> some View {
667
        VStack(alignment: .leading, spacing: 4) {
668
            Text(title)
669
                .font(.caption.weight(.semibold))
670
                .foregroundColor(.secondary)
671
            Text(value)
672
                .font(.subheadline.weight(.bold))
673
                .monospacedDigit()
674
        }
675
        .padding(.horizontal, 12)
676
        .padding(.vertical, 10)
677
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
678
    }
679

            
680
    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
681
        let labels = (1...xLabels).map {
682
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
683
        }
684

            
685
        return HStack {
686
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
687
                Text(item.element)
688
                    .font(.caption2.weight(.semibold))
689
                    .monospacedDigit()
690
                    .frame(maxWidth: .infinity)
691
            }
692
        }
693
        .foregroundColor(.secondary)
694
    }
695

            
696
    private func rssiYAxisView(context: ChartContext) -> some View {
697
        VStack(spacing: 0) {
698
            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
699
                Spacer(minLength: 0)
700
                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
701
                    .font(.caption2.weight(.semibold))
702
                    .monospacedDigit()
703
                    .foregroundColor(.primary)
704
                Spacer(minLength: 0)
705
            }
706
        }
707
        .padding(.vertical, 12)
708
        .background(
709
            RoundedRectangle(cornerRadius: 16, style: .continuous)
710
                .fill(Color.mint.opacity(0.12))
711
        )
712
        .overlay(
713
            RoundedRectangle(cornerRadius: 16, style: .continuous)
714
                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
715
        )
716
    }
717

            
718
    private func rssiHorizontalGuides(context: ChartContext) -> some View {
719
        GeometryReader { geometry in
720
            Path { path in
721
                for labelIndex in 1...yLabels {
722
                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
723
                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
724
                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
725
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
726
                }
727
            }
728
            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
729
        }
730
    }
731

            
732
    private func rssiVerticalGuides(context: ChartContext) -> some View {
733
        GeometryReader { geometry in
734
            Path { path in
735
                for labelIndex in 2..<xLabels {
736
                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
737
                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
738
                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
739
                    path.move(to: CGPoint(x: x, y: 0))
740
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
741
                }
742
            }
743
            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
744
        }
745
    }
Bogdan Timofte authored 2 months ago
746
}
Bogdan Timofte authored a month ago
747

            
748
private struct EnergyProjectionSheetView: View {
749
    @EnvironmentObject private var measurements: Measurements
750
    @EnvironmentObject private var meter: Meter
751

            
752
    @Binding var visibility: Bool
753
    @State private var selectedProjectionMethodID: String = ""
754

            
755
    var body: some View {
756
        let snapshot = measurements.energyProjectionSnapshot()
757
        let projectionVariants = measurements.energyProjectionVariants()
758
        let projectionVariantIDs = projectionVariants.map(\.id)
759
        let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants)
760

            
761
        NavigationView {
762
            ScrollView {
763
                VStack(alignment: .leading, spacing: 14) {
764
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
765
                        HStack(spacing: 8) {
766
                            Text("Energy Projections")
767
                                .font(.system(.title3, design: .rounded).weight(.bold))
768
                            ContextInfoButton(
769
                                title: "Energy Projections",
770
                                message: "Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data."
771
                            )
772
                        }
Bogdan Timofte authored a month ago
773
                    }
774
                    .padding(18)
775
                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
776

            
777
                    MeterInfoCardView(title: "Current Session", tint: meter.color) {
778
                        if let snapshot {
779
                            MeterInfoRowView(
780
                                label: "Accumulated Energy",
781
                                value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh"
782
                            )
783
                            MeterInfoRowView(
784
                                label: "Observed Interval",
785
                                value: observedIntervalText(snapshot.observedDuration)
786
                            )
787
                            MeterInfoRowView(
788
                                label: "Buffered Samples",
789
                                value: "\(snapshot.sampleCount)"
790
                            )
791
                            MeterInfoRowView(
792
                                label: "Average Power",
793
                                value: averagePowerText(snapshot.averagePower)
794
                            )
795
                        } else {
796
                            Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.")
797
                                .font(.footnote)
798
                                .foregroundColor(.secondary)
799
                        }
800
                    }
801

            
Bogdan Timofte authored a month ago
802
                    MeterInfoCardView(
803
                        title: "Projection Method",
804
                        infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.",
805
                        tint: .teal
806
                    ) {
Bogdan Timofte authored a month ago
807
                        if projectionVariants.isEmpty {
Bogdan Timofte authored a month ago
808
                            Text("No projection methods available yet.")
Bogdan Timofte authored a month ago
809
                                .font(.footnote)
810
                                .foregroundColor(.secondary)
811
                        } else {
812
                            VStack(alignment: .leading, spacing: 14) {
813
                                Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) {
814
                                    ForEach(projectionVariants) { variant in
815
                                        Text(variant.title).tag(variant.id)
816
                                    }
817
                                }
818
                                .pickerStyle(.menu)
819

            
820
                                if let selectedProjectionVariant {
821
                                    projectionVariantView(selectedProjectionVariant)
822
                                }
823
                            }
824
                        }
825
                    }
826
                }
827
                .padding()
828
                .padding(.top, 8)
829
            }
830
            .background(
831
                LinearGradient(
832
                    colors: [.teal.opacity(0.14), Color.clear],
833
                    startPoint: .topLeading,
834
                    endPoint: .bottomTrailing
835
                )
836
                .ignoresSafeArea()
837
            )
838
            .navigationTitle("Energy")
839
            .navigationBarTitleDisplayMode(.inline)
840
            .toolbar {
841
                ToolbarItem(placement: .cancellationAction) {
842
                    Button("Done") { visibility.toggle() }
843
                }
844
            }
845
        }
846
        .navigationViewStyle(StackNavigationViewStyle())
847
        .onAppear {
848
            updateSelectedProjectionMethod(with: projectionVariants)
849
        }
850
        .onChange(of: projectionVariantIDs) { _ in
851
            updateSelectedProjectionMethod(with: projectionVariants)
852
        }
853
    }
854

            
855
    private func projectionRow(title: String, value: String) -> some View {
856
        MeterInfoRowView(label: title, value: value)
857
    }
858

            
859
    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
860
        VStack(alignment: .leading, spacing: 8) {
861
            Text(variant.title)
862
                .font(.subheadline.weight(.semibold))
863

            
864
            projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration))
865
            projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy))
866
            projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower))
867
            projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy))
868
            projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy))
869
        }
870
        .padding(.bottom, 2)
871
    }
872

            
873
    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
874
        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
875
            return selectedVariant
876
        }
877

            
878
        return variants.last
879
    }
880

            
881
    private func selectedProjectionMethodBinding(
882
        for variants: [Measurements.EnergyProjectionVariant]
883
    ) -> Binding<String> {
884
        Binding(
885
            get: {
886
                resolvedProjectionVariant(from: variants)?.id ?? ""
887
            },
888
            set: { newValue in
889
                selectedProjectionMethodID = newValue
890
            }
891
        )
892
    }
893

            
894
    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
895
        guard !variants.isEmpty else {
896
            selectedProjectionMethodID = ""
897
            return
898
        }
899

            
900
        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
901
            return
902
        }
903

            
904
        selectedProjectionMethodID = variants.last?.id ?? ""
905
    }
906

            
907
    private func observedIntervalText(_ duration: TimeInterval) -> String {
908
        guard duration > 0 else { return "Insufficient data" }
909

            
910
        let totalSeconds = Int(duration.rounded())
911
        let hours = totalSeconds / 3600
912
        let minutes = (totalSeconds % 3600) / 60
913
        let seconds = totalSeconds % 60
914

            
915
        if hours > 0 {
916
            return "\(hours)h \(minutes)m"
917
        }
918

            
919
        if minutes > 0 {
920
            return "\(minutes)m \(seconds)s"
921
        }
922

            
923
        return "\(seconds)s"
924
    }
925

            
926
    private func averagePowerText(_ averagePower: Double?) -> String {
927
        guard let averagePower, averagePower.isFinite else {
928
            return "Insufficient data"
929
        }
930

            
931
        return "\(averagePower.format(decimalDigits: 3)) W"
932
    }
933

            
934
    private func averagePowerText(_ averagePower: Double) -> String {
935
        averagePowerText(Optional(averagePower))
936
    }
937

            
938
    private func energyText(_ energy: Double) -> String {
939
        if energy >= 1000 {
940
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
941
        }
942

            
943
        return "\(energy.format(decimalDigits: 3)) Wh"
944
    }
945

            
946
    private func projectedEnergyText(_ energy: Double?) -> String {
947
        guard let energy, energy.isFinite else {
948
            return "Insufficient data"
949
        }
950

            
951
        if energy >= 1000 {
952
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
953
        }
954

            
955
        return "\(energy.format(decimalDigits: 1)) Wh"
956
    }
957
}