Newer Older
946 lines | 38.423kb
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)
Bogdan Timofte authored a month ago
618
                                        TimeSeriesChart(points: points, context: chartContext, strokeColor: .mint)
Bogdan Timofte authored 2 months ago
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 {
Bogdan Timofte authored a month ago
719
        TimeSeriesChartHorizontalGuides(
720
            context: context,
721
            labelCount: yLabels,
722
            strokeColor: Color.secondary.opacity(0.30),
723
            lineWidth: 0.8
724
        )
Bogdan Timofte authored 2 months ago
725
    }
726

            
727
    private func rssiVerticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
728
        TimeSeriesChartVerticalGuides(
729
            context: context,
730
            labelCount: xLabels,
731
            strokeColor: Color.secondary.opacity(0.26),
732
            strokeStyle: StrokeStyle(lineWidth: 0.8, dash: [4, 4])
733
        )
Bogdan Timofte authored 2 months ago
734
    }
Bogdan Timofte authored 2 months ago
735
}
Bogdan Timofte authored a month ago
736

            
737
private struct EnergyProjectionSheetView: View {
738
    @EnvironmentObject private var measurements: Measurements
739
    @EnvironmentObject private var meter: Meter
740

            
741
    @Binding var visibility: Bool
742
    @State private var selectedProjectionMethodID: String = ""
743

            
744
    var body: some View {
745
        let snapshot = measurements.energyProjectionSnapshot()
746
        let projectionVariants = measurements.energyProjectionVariants()
747
        let projectionVariantIDs = projectionVariants.map(\.id)
748
        let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants)
749

            
750
        NavigationView {
751
            ScrollView {
752
                VStack(alignment: .leading, spacing: 14) {
753
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
754
                        HStack(spacing: 8) {
755
                            Text("Energy Projections")
756
                                .font(.system(.title3, design: .rounded).weight(.bold))
757
                            ContextInfoButton(
758
                                title: "Energy Projections",
759
                                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."
760
                            )
761
                        }
Bogdan Timofte authored a month ago
762
                    }
763
                    .padding(18)
764
                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
765

            
766
                    MeterInfoCardView(title: "Current Session", tint: meter.color) {
767
                        if let snapshot {
768
                            MeterInfoRowView(
769
                                label: "Accumulated Energy",
770
                                value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh"
771
                            )
772
                            MeterInfoRowView(
773
                                label: "Observed Interval",
774
                                value: observedIntervalText(snapshot.observedDuration)
775
                            )
776
                            MeterInfoRowView(
777
                                label: "Buffered Samples",
778
                                value: "\(snapshot.sampleCount)"
779
                            )
780
                            MeterInfoRowView(
781
                                label: "Average Power",
782
                                value: averagePowerText(snapshot.averagePower)
783
                            )
784
                        } else {
785
                            Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.")
786
                                .font(.footnote)
787
                                .foregroundColor(.secondary)
788
                        }
789
                    }
790

            
Bogdan Timofte authored a month ago
791
                    MeterInfoCardView(
792
                        title: "Projection Method",
793
                        infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.",
794
                        tint: .teal
795
                    ) {
Bogdan Timofte authored a month ago
796
                        if projectionVariants.isEmpty {
Bogdan Timofte authored a month ago
797
                            Text("No projection methods available yet.")
Bogdan Timofte authored a month ago
798
                                .font(.footnote)
799
                                .foregroundColor(.secondary)
800
                        } else {
801
                            VStack(alignment: .leading, spacing: 14) {
802
                                Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) {
803
                                    ForEach(projectionVariants) { variant in
804
                                        Text(variant.title).tag(variant.id)
805
                                    }
806
                                }
807
                                .pickerStyle(.menu)
808

            
809
                                if let selectedProjectionVariant {
810
                                    projectionVariantView(selectedProjectionVariant)
811
                                }
812
                            }
813
                        }
814
                    }
815
                }
816
                .padding()
817
                .padding(.top, 8)
818
            }
819
            .background(
820
                LinearGradient(
821
                    colors: [.teal.opacity(0.14), Color.clear],
822
                    startPoint: .topLeading,
823
                    endPoint: .bottomTrailing
824
                )
825
                .ignoresSafeArea()
826
            )
827
            .navigationTitle("Energy")
828
            .navigationBarTitleDisplayMode(.inline)
829
            .toolbar {
830
                ToolbarItem(placement: .cancellationAction) {
831
                    Button("Done") { visibility.toggle() }
832
                }
833
            }
834
        }
835
        .navigationViewStyle(StackNavigationViewStyle())
836
        .onAppear {
837
            updateSelectedProjectionMethod(with: projectionVariants)
838
        }
839
        .onChange(of: projectionVariantIDs) { _ in
840
            updateSelectedProjectionMethod(with: projectionVariants)
841
        }
842
    }
843

            
844
    private func projectionRow(title: String, value: String) -> some View {
845
        MeterInfoRowView(label: title, value: value)
846
    }
847

            
848
    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
849
        VStack(alignment: .leading, spacing: 8) {
850
            Text(variant.title)
851
                .font(.subheadline.weight(.semibold))
852

            
853
            projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration))
854
            projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy))
855
            projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower))
856
            projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy))
857
            projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy))
858
        }
859
        .padding(.bottom, 2)
860
    }
861

            
862
    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
863
        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
864
            return selectedVariant
865
        }
866

            
867
        return variants.last
868
    }
869

            
870
    private func selectedProjectionMethodBinding(
871
        for variants: [Measurements.EnergyProjectionVariant]
872
    ) -> Binding<String> {
873
        Binding(
874
            get: {
875
                resolvedProjectionVariant(from: variants)?.id ?? ""
876
            },
877
            set: { newValue in
878
                selectedProjectionMethodID = newValue
879
            }
880
        )
881
    }
882

            
883
    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
884
        guard !variants.isEmpty else {
885
            selectedProjectionMethodID = ""
886
            return
887
        }
888

            
889
        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
890
            return
891
        }
892

            
893
        selectedProjectionMethodID = variants.last?.id ?? ""
894
    }
895

            
896
    private func observedIntervalText(_ duration: TimeInterval) -> String {
897
        guard duration > 0 else { return "Insufficient data" }
898

            
899
        let totalSeconds = Int(duration.rounded())
900
        let hours = totalSeconds / 3600
901
        let minutes = (totalSeconds % 3600) / 60
902
        let seconds = totalSeconds % 60
903

            
904
        if hours > 0 {
905
            return "\(hours)h \(minutes)m"
906
        }
907

            
908
        if minutes > 0 {
909
            return "\(minutes)m \(seconds)s"
910
        }
911

            
912
        return "\(seconds)s"
913
    }
914

            
915
    private func averagePowerText(_ averagePower: Double?) -> String {
916
        guard let averagePower, averagePower.isFinite else {
917
            return "Insufficient data"
918
        }
919

            
920
        return "\(averagePower.format(decimalDigits: 3)) W"
921
    }
922

            
923
    private func averagePowerText(_ averagePower: Double) -> String {
924
        averagePowerText(Optional(averagePower))
925
    }
926

            
927
    private func energyText(_ energy: Double) -> String {
928
        if energy >= 1000 {
929
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
930
        }
931

            
932
        return "\(energy.format(decimalDigits: 3)) Wh"
933
    }
934

            
935
    private func projectedEnergyText(_ energy: Double?) -> String {
936
        guard let energy, energy.isFinite else {
937
            return "Insufficient data"
938
        }
939

            
940
        if energy >= 1000 {
941
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
942
        }
943

            
944
        return "\(energy.format(decimalDigits: 1)) Wh"
945
    }
946
}