Newer Older
909 lines | 36.725kb
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 2 months ago
39
            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
Bogdan Timofte authored 2 months ago
40
                if shouldShowVoltageCard {
41
                    liveMetricCard(
42
                        title: "Voltage",
43
                        symbol: "bolt.fill",
44
                        color: .green,
45
                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
46
                        range: metricRange(
47
                            min: meter.measurements.voltage.context.minValue,
48
                            max: meter.measurements.voltage.context.maxValue,
49
                            unit: "V"
50
                        )
Bogdan Timofte authored 2 months ago
51
                    )
Bogdan Timofte authored 2 months ago
52
                }
Bogdan Timofte authored 2 months ago
53

            
Bogdan Timofte authored 2 months ago
54
                if shouldShowCurrentCard {
55
                    liveMetricCard(
56
                        title: "Current",
57
                        symbol: "waveform.path.ecg",
58
                        color: .blue,
59
                        value: "\(meter.current.format(decimalDigits: 3)) A",
60
                        range: metricRange(
61
                            min: meter.measurements.current.context.minValue,
62
                            max: meter.measurements.current.context.maxValue,
63
                            unit: "A"
64
                        )
Bogdan Timofte authored 2 months ago
65
                    )
Bogdan Timofte authored 2 months ago
66
                }
Bogdan Timofte authored 2 months ago
67

            
Bogdan Timofte authored 2 months ago
68
                if shouldShowPowerCard {
69
                    liveMetricCard(
70
                        title: "Power",
71
                        symbol: "flame.fill",
72
                        color: .pink,
73
                        value: "\(meter.power.format(decimalDigits: 3)) W",
74
                        range: metricRange(
75
                            min: meter.measurements.power.context.minValue,
76
                            max: meter.measurements.power.context.maxValue,
77
                            unit: "W"
Bogdan Timofte authored 2 months ago
78
                        ),
79
                        action: {
80
                            powerAverageSheetVisibility = true
81
                        }
Bogdan Timofte authored 2 months ago
82
                    )
Bogdan Timofte authored 2 months ago
83
                }
Bogdan Timofte authored 2 months ago
84

            
Bogdan Timofte authored 2 months ago
85
                if shouldShowEnergyCard {
86
                    liveMetricCard(
Bogdan Timofte authored a month ago
87
                        title: "Accumulated Energy",
Bogdan Timofte authored 2 months ago
88
                        symbol: "battery.100.bolt",
89
                        color: .teal,
90
                        value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
Bogdan Timofte authored a month ago
91
                        detailText: "Tap for monthly and yearly projections",
92
                        action: {
93
                            energyProjectionSheetVisibility = true
94
                        }
Bogdan Timofte authored 2 months ago
95
                    )
96
                }
97

            
Bogdan Timofte authored 2 months ago
98
                if shouldShowTemperatureCard {
99
                    liveMetricCard(
100
                        title: "Temperature",
101
                        symbol: "thermometer.medium",
102
                        color: .orange,
103
                        value: meter.primaryTemperatureDescription,
Bogdan Timofte authored 2 months ago
104
                        range: temperatureRange(
105
                            min: meter.measurements.temperature.context.minValue,
106
                            max: meter.measurements.temperature.context.maxValue
107
                        )
Bogdan Timofte authored 2 months ago
108
                    )
109
                }
Bogdan Timofte authored 2 months ago
110

            
Bogdan Timofte authored 2 months ago
111
                if shouldShowLoadCard {
112
                    liveMetricCard(
113
                        title: "Load",
114
                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
115
                        color: .yellow,
116
                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
117
                        detailText: "Measured resistance"
118
                    )
119
                }
Bogdan Timofte authored 2 months ago
120

            
Bogdan Timofte authored 2 months ago
121
                liveMetricCard(
122
                    title: "RSSI",
123
                    symbol: "dot.radiowaves.left.and.right",
124
                    color: .mint,
Bogdan Timofte authored 2 months ago
125
                    value: "\(meter.btSerial.averageRSSI) dBm",
Bogdan Timofte authored 2 months ago
126
                    range: metricRange(
127
                        min: meter.measurements.rssi.context.minValue,
128
                        max: meter.measurements.rssi.context.maxValue,
129
                        unit: "dBm",
130
                        decimalDigits: 0
Bogdan Timofte authored 2 months ago
131
                    ),
Bogdan Timofte authored 2 months ago
132
                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
133
                    action: {
134
                        rssiHistorySheetVisibility = true
135
                    }
Bogdan Timofte authored 2 months ago
136
                )
Bogdan Timofte authored 2 months ago
137

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

            
Bogdan Timofte authored 2 months ago
169
    private var hasLiveMetrics: Bool {
170
        meter.operationalState == .dataIsAvailable
171
    }
172

            
173
    private var shouldShowVoltageCard: Bool {
174
        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
175
    }
176

            
177
    private var shouldShowCurrentCard: Bool {
178
        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
179
    }
180

            
181
    private var shouldShowPowerCard: Bool {
182
        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
183
    }
184

            
Bogdan Timofte authored 2 months ago
185
    private var shouldShowEnergyCard: Bool {
186
        hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite
187
    }
188

            
Bogdan Timofte authored 2 months ago
189
    private var shouldShowTemperatureCard: Bool {
190
        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
191
    }
192

            
Bogdan Timofte authored 2 months ago
193
    private var liveBufferedEnergyValue: Double {
194
        meter.measurements.energy.samplePoints.last?.value ?? 0
195
    }
196

            
Bogdan Timofte authored 2 months ago
197
    private var shouldShowLoadCard: Bool {
198
        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
199
    }
200

            
Bogdan Timofte authored 2 months ago
201
    private var liveMetricColumns: [GridItem] {
202
        if compactLayout {
Bogdan Timofte authored 2 months ago
203
            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
Bogdan Timofte authored 2 months ago
204
        }
205

            
206
        return [GridItem(.flexible()), GridItem(.flexible())]
Bogdan Timofte authored 2 months ago
207
    }
Bogdan Timofte authored 2 months ago
208

            
209
    private var statusBadge: some View {
210
        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
211
            .font(.caption.weight(.semibold))
212
            .padding(.horizontal, 10)
213
            .padding(.vertical, 6)
214
            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
215
            .meterCard(
216
                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
217
                fillOpacity: 0.12,
218
                strokeOpacity: 0.16,
219
                cornerRadius: 999
220
            )
221
    }
222

            
Bogdan Timofte authored a month ago
223

            
Bogdan Timofte authored 2 months ago
224
    private var showsCompactMetricRange: Bool {
225
        compactLayout && (availableSize?.height ?? 0) >= 380
226
    }
227

            
228
    private var shouldShowMetricRange: Bool {
229
        !compactLayout || showsCompactMetricRange
230
    }
231

            
Bogdan Timofte authored 2 months ago
232
    private func liveMetricCard(
233
        title: String,
Bogdan Timofte authored 2 months ago
234
        symbol: String? = nil,
235
        customSymbol: AnyView? = nil,
Bogdan Timofte authored 2 months ago
236
        color: Color,
237
        value: String,
Bogdan Timofte authored 2 months ago
238
        range: MeterLiveMetricRange? = nil,
Bogdan Timofte authored 2 months ago
239
        detailText: String? = nil,
240
        valueFont: Font? = nil,
241
        valueLineLimit: Int = 1,
242
        valueMonospacedDigits: Bool = true,
Bogdan Timofte authored 2 months ago
243
        valueMinimumScaleFactor: CGFloat = 0.85,
244
        action: (() -> Void)? = nil
Bogdan Timofte authored 2 months ago
245
    ) -> some View {
Bogdan Timofte authored 2 months ago
246
        let cardContent = VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored 2 months ago
247
            HStack(spacing: compactLayout ? 8 : 10) {
Bogdan Timofte authored 2 months ago
248
                Group {
249
                    if let customSymbol {
250
                        customSymbol
251
                    } else if let symbol {
252
                        Image(systemName: symbol)
253
                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
254
                            .foregroundColor(color)
255
                    }
256
                }
Bogdan Timofte authored 2 months ago
257
                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
Bogdan Timofte authored 2 months ago
258
                .background(Circle().fill(color.opacity(0.12)))
Bogdan Timofte authored 2 months ago
259

            
Bogdan Timofte authored 2 months ago
260
                Text(title)
261
                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
262
                    .foregroundColor(.secondary)
263
                    .lineLimit(1)
264

            
265
                Spacer(minLength: 0)
266
            }
Bogdan Timofte authored 2 months ago
267

            
Bogdan Timofte authored 2 months ago
268
            Group {
269
                if valueMonospacedDigits {
270
                    Text(value)
271
                        .monospacedDigit()
272
                } else {
273
                    Text(value)
274
                }
275
            }
276
            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
277
            .lineLimit(valueLineLimit)
278
            .minimumScaleFactor(valueMinimumScaleFactor)
Bogdan Timofte authored 2 months ago
279

            
Bogdan Timofte authored 2 months ago
280
            if shouldShowMetricRange {
281
                if let range {
282
                    metricRangeTable(range)
283
                } else if let detailText, !detailText.isEmpty {
284
                    Text(detailText)
285
                        .font(.caption)
286
                        .foregroundColor(.secondary)
287
                        .lineLimit(2)
288
                }
Bogdan Timofte authored 2 months ago
289
            }
290
        }
Bogdan Timofte authored 2 months ago
291
        .frame(
292
            maxWidth: .infinity,
Bogdan Timofte authored 2 months ago
293
            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
Bogdan Timofte authored 2 months ago
294
            alignment: .leading
295
        )
296
        .padding(compactLayout ? 12 : 16)
Bogdan Timofte authored 2 months ago
297
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
Bogdan Timofte authored 2 months ago
298

            
299
        if let action {
300
            return AnyView(
301
                Button(action: action) {
302
                    cardContent
303
                }
304
                .buttonStyle(.plain)
305
            )
306
        }
307

            
308
        return AnyView(cardContent)
Bogdan Timofte authored 2 months ago
309
    }
310

            
Bogdan Timofte authored 2 months ago
311
    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
Bogdan Timofte authored 2 months ago
312
        VStack(alignment: .leading, spacing: 4) {
313
            HStack(spacing: 12) {
314
                Text(range.minLabel)
315
                Spacer(minLength: 0)
316
                Text(range.maxLabel)
317
            }
318
            .font(.caption2.weight(.semibold))
319
            .foregroundColor(.secondary)
320

            
321
            HStack(spacing: 12) {
322
                Text(range.minValue)
323
                    .monospacedDigit()
324
                Spacer(minLength: 0)
325
                Text(range.maxValue)
326
                    .monospacedDigit()
327
            }
328
            .font(.caption.weight(.medium))
329
            .foregroundColor(.primary)
330
        }
331
    }
332

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

            
Bogdan Timofte authored 2 months ago
336
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 months ago
337
            minLabel: "Min",
338
            maxLabel: "Max",
Bogdan Timofte authored 2 months ago
339
            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
340
            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
Bogdan Timofte authored 2 months ago
341
        )
342
    }
343

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

            
347
        let unitSuffix = temperatureUnitSuffix()
Bogdan Timofte authored 2 months ago
348

            
Bogdan Timofte authored 2 months ago
349
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 months ago
350
            minLabel: "Min",
351
            maxLabel: "Max",
Bogdan Timofte authored 2 months ago
352
            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
353
            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
Bogdan Timofte authored 2 months ago
354
        )
Bogdan Timofte authored 2 months ago
355
    }
Bogdan Timofte authored 2 months ago
356

            
357
    private func meterHistoryText(for date: Date?) -> String {
358
        guard let date else {
359
            return "Never"
360
        }
361
        return date.format(as: "yyyy-MM-dd HH:mm")
362
    }
Bogdan Timofte authored 2 months ago
363

            
364
    private func temperatureUnitSuffix() -> String {
365
        if meter.supportsManualTemperatureUnitSelection {
366
            return "°"
367
        }
368

            
369
        let locale = Locale.autoupdatingCurrent
370
        if #available(iOS 16.0, *) {
371
            switch locale.measurementSystem {
372
            case .us:
373
                return "°F"
374
            default:
375
                return "°C"
376
            }
377
        }
378

            
379
        let regionCode = locale.regionCode ?? ""
380
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
381
        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
382
    }
383
}
384

            
385
private struct PowerAverageSheetView: View {
386
    @EnvironmentObject private var measurements: Measurements
387

            
388
    @Binding var visibility: Bool
389

            
390
    @State private var selectedSampleCount: Int = 20
391

            
392
    var body: some View {
393
        let bufferedSamples = measurements.powerSampleCount()
394

            
395
        NavigationView {
396
            ScrollView {
397
                VStack(alignment: .leading, spacing: 14) {
398
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
399
                        HStack(spacing: 8) {
400
                            Text("Power Average")
401
                                .font(.system(.title3, design: .rounded).weight(.bold))
402
                            ContextInfoButton(
403
                                title: "Power Average",
404
                                message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window."
405
                            )
406
                        }
Bogdan Timofte authored 2 months ago
407
                    }
408
                    .padding(18)
409
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
410

            
411
                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
412
                        if bufferedSamples == 0 {
413
                            Text("No power samples are available yet.")
414
                                .font(.footnote)
415
                                .foregroundColor(.secondary)
416
                        } else {
417
                            VStack(alignment: .leading, spacing: 14) {
418
                                VStack(alignment: .leading, spacing: 8) {
419
                                    Text("Values used")
420
                                        .font(.subheadline.weight(.semibold))
421

            
422
                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
423
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
424
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
425
                                        }
426
                                    }
427
                                    .pickerStyle(.menu)
428
                                }
429

            
430
                                VStack(alignment: .leading, spacing: 6) {
431
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
432
                                        .font(.system(.title2, design: .rounded).weight(.bold))
433
                                        .monospacedDigit()
434

            
435
                                    Text("Buffered samples: \(bufferedSamples)")
436
                                        .font(.caption)
437
                                        .foregroundColor(.secondary)
438
                                }
439
                            }
440
                        }
441
                    }
442

            
Bogdan Timofte authored a month ago
443
                    MeterInfoCardView(
444
                        title: "Buffer Actions",
445
                        infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.",
446
                        tint: .secondary
447
                    ) {
Bogdan Timofte authored 2 months ago
448
                        Button("Reset Buffer") {
449
                            measurements.resetSeries()
450
                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
451
                        }
452
                        .foregroundColor(.red)
453
                    }
454
                }
455
                .padding()
456
            }
457
            .background(
458
                LinearGradient(
459
                    colors: [.pink.opacity(0.14), Color.clear],
460
                    startPoint: .topLeading,
461
                    endPoint: .bottomTrailing
462
                )
463
                .ignoresSafeArea()
464
            )
465
            .navigationBarItems(
466
                leading: Button("Done") { visibility.toggle() }
467
            )
468
            .navigationBarTitle("Power", displayMode: .inline)
469
        }
470
        .navigationViewStyle(StackNavigationViewStyle())
471
        .onAppear {
472
            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
473
        }
474
        .onChange(of: bufferedSamples) { newValue in
475
            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
476
        }
477
    }
478

            
479
    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
480
        guard bufferedSamples > 0 else { return [] }
481

            
482
        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
483
        return (filtered + [bufferedSamples]).sorted()
484
    }
485

            
486
    private func defaultSampleCount(bufferedSamples: Int) -> Int {
487
        guard bufferedSamples > 0 else { return 20 }
488
        return min(20, bufferedSamples)
489
    }
490

            
491
    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
492
        Binding(
493
            get: {
494
                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
495
                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
496
                if availableOptions.contains(selectedSampleCount) {
497
                    return selectedSampleCount
498
                }
499
                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
500
            },
501
            set: { newValue in
502
                selectedSampleCount = newValue
503
            }
504
        )
505
    }
506

            
507
    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
508
        if option == bufferedSamples {
509
            return "All (\(option))"
510
        }
511
        return "\(option) values"
512
    }
513

            
514
    private func averagePowerLabel(bufferedSamples: Int) -> String {
515
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
516
            return "No data"
517
        }
518

            
519
        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
520
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
521
    }
522
}
523

            
524
private struct RSSIHistorySheetView: View {
525
    @EnvironmentObject private var measurements: Measurements
526

            
527
    @Binding var visibility: Bool
528

            
529
    private let xLabels: Int = 4
530
    private let yLabels: Int = 4
531

            
532
    var body: some View {
533
        let points = measurements.rssi.points
534
        let samplePoints = measurements.rssi.samplePoints
535
        let chartContext = buildChartContext(for: samplePoints)
536

            
537
        NavigationView {
538
            ScrollView {
539
                VStack(alignment: .leading, spacing: 14) {
540
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
541
                        HStack(spacing: 8) {
542
                            Text("RSSI History")
543
                                .font(.system(.title3, design: .rounded).weight(.bold))
544
                            ContextInfoButton(
545
                                title: "RSSI History",
546
                                message: "Signal strength captured over time while the meter stays connected."
547
                            )
548
                        }
Bogdan Timofte authored 2 months ago
549
                    }
550
                    .padding(18)
551
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
552

            
553
                    if samplePoints.isEmpty {
554
                        Text("No RSSI samples have been captured yet.")
555
                            .font(.footnote)
556
                            .foregroundColor(.secondary)
557
                            .padding(18)
558
                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
559
                    } else {
560
                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
561
                            VStack(alignment: .leading, spacing: 12) {
562
                                HStack(spacing: 12) {
563
                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
564
                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
565
                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
566
                                }
567

            
568
                                HStack(spacing: 8) {
569
                                    rssiYAxisView(context: chartContext)
570
                                        .frame(width: 52, height: 220)
571

            
572
                                    ZStack {
573
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
574
                                            .fill(Color.primary.opacity(0.05))
575

            
576
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
577
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
578

            
579
                                        rssiHorizontalGuides(context: chartContext)
580
                                        rssiVerticalGuides(context: chartContext)
Bogdan Timofte authored a month ago
581
                                        TimeSeriesChart(points: points, context: chartContext, strokeColor: .mint)
Bogdan Timofte authored 2 months ago
582
                                            .opacity(0.82)
583
                                    }
584
                                    .frame(maxWidth: .infinity)
585
                                    .frame(height: 220)
586
                                }
587

            
588
                                rssiXAxisLabelsView(context: chartContext)
589
                                    .frame(height: 28)
590
                            }
591
                        }
592
                    }
593
                }
594
                .padding()
595
            }
596
            .background(
597
                LinearGradient(
598
                    colors: [.mint.opacity(0.14), Color.clear],
599
                    startPoint: .topLeading,
600
                    endPoint: .bottomTrailing
601
                )
602
                .ignoresSafeArea()
603
            )
604
            .navigationBarItems(
605
                leading: Button("Done") { visibility.toggle() }
606
            )
607
            .navigationBarTitle("RSSI", displayMode: .inline)
608
        }
609
        .navigationViewStyle(StackNavigationViewStyle())
610
    }
611

            
612
    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
613
        let context = ChartContext()
614
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
615
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
616
        let minimumValue = samplePoints.map(\.value).min() ?? -100
617
        let maximumValue = samplePoints.map(\.value).max() ?? -40
618
        let padding = max((maximumValue - minimumValue) * 0.12, 4)
619

            
620
        context.setBounds(
621
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
622
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
623
            yMin: CGFloat(minimumValue - padding),
624
            yMax: CGFloat(maximumValue + padding)
625
        )
626
        return context
627
    }
628

            
629
    private func signalSummaryChip(title: String, value: String) -> some View {
630
        VStack(alignment: .leading, spacing: 4) {
631
            Text(title)
632
                .font(.caption.weight(.semibold))
633
                .foregroundColor(.secondary)
634
            Text(value)
635
                .font(.subheadline.weight(.bold))
636
                .monospacedDigit()
637
        }
638
        .padding(.horizontal, 12)
639
        .padding(.vertical, 10)
640
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
641
    }
642

            
643
    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
644
        let labels = (1...xLabels).map {
645
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
646
        }
647

            
648
        return HStack {
649
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
650
                Text(item.element)
651
                    .font(.caption2.weight(.semibold))
652
                    .monospacedDigit()
653
                    .frame(maxWidth: .infinity)
654
            }
655
        }
656
        .foregroundColor(.secondary)
657
    }
658

            
659
    private func rssiYAxisView(context: ChartContext) -> some View {
660
        VStack(spacing: 0) {
661
            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
662
                Spacer(minLength: 0)
663
                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
664
                    .font(.caption2.weight(.semibold))
665
                    .monospacedDigit()
666
                    .foregroundColor(.primary)
667
                Spacer(minLength: 0)
668
            }
669
        }
670
        .padding(.vertical, 12)
671
        .background(
672
            RoundedRectangle(cornerRadius: 16, style: .continuous)
673
                .fill(Color.mint.opacity(0.12))
674
        )
675
        .overlay(
676
            RoundedRectangle(cornerRadius: 16, style: .continuous)
677
                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
678
        )
679
    }
680

            
681
    private func rssiHorizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
682
        TimeSeriesChartHorizontalGuides(
683
            context: context,
684
            labelCount: yLabels,
685
            strokeColor: Color.secondary.opacity(0.30),
686
            lineWidth: 0.8
687
        )
Bogdan Timofte authored 2 months ago
688
    }
689

            
690
    private func rssiVerticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
691
        TimeSeriesChartVerticalGuides(
692
            context: context,
693
            labelCount: xLabels,
694
            strokeColor: Color.secondary.opacity(0.26),
695
            strokeStyle: StrokeStyle(lineWidth: 0.8, dash: [4, 4])
696
        )
Bogdan Timofte authored 2 months ago
697
    }
Bogdan Timofte authored 2 months ago
698
}
Bogdan Timofte authored a month ago
699

            
700
private struct EnergyProjectionSheetView: View {
701
    @EnvironmentObject private var measurements: Measurements
702
    @EnvironmentObject private var meter: Meter
703

            
704
    @Binding var visibility: Bool
705
    @State private var selectedProjectionMethodID: String = ""
706

            
707
    var body: some View {
708
        let snapshot = measurements.energyProjectionSnapshot()
709
        let projectionVariants = measurements.energyProjectionVariants()
710
        let projectionVariantIDs = projectionVariants.map(\.id)
711
        let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants)
712

            
713
        NavigationView {
714
            ScrollView {
715
                VStack(alignment: .leading, spacing: 14) {
716
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
717
                        HStack(spacing: 8) {
718
                            Text("Energy Projections")
719
                                .font(.system(.title3, design: .rounded).weight(.bold))
720
                            ContextInfoButton(
721
                                title: "Energy Projections",
722
                                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."
723
                            )
724
                        }
Bogdan Timofte authored a month ago
725
                    }
726
                    .padding(18)
727
                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
728

            
729
                    MeterInfoCardView(title: "Current Session", tint: meter.color) {
730
                        if let snapshot {
731
                            MeterInfoRowView(
732
                                label: "Accumulated Energy",
733
                                value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh"
734
                            )
735
                            MeterInfoRowView(
736
                                label: "Observed Interval",
737
                                value: observedIntervalText(snapshot.observedDuration)
738
                            )
739
                            MeterInfoRowView(
740
                                label: "Buffered Samples",
741
                                value: "\(snapshot.sampleCount)"
742
                            )
743
                            MeterInfoRowView(
744
                                label: "Average Power",
745
                                value: averagePowerText(snapshot.averagePower)
746
                            )
747
                        } else {
748
                            Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.")
749
                                .font(.footnote)
750
                                .foregroundColor(.secondary)
751
                        }
752
                    }
753

            
Bogdan Timofte authored a month ago
754
                    MeterInfoCardView(
755
                        title: "Projection Method",
756
                        infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.",
757
                        tint: .teal
758
                    ) {
Bogdan Timofte authored a month ago
759
                        if projectionVariants.isEmpty {
Bogdan Timofte authored a month ago
760
                            Text("No projection methods available yet.")
Bogdan Timofte authored a month ago
761
                                .font(.footnote)
762
                                .foregroundColor(.secondary)
763
                        } else {
764
                            VStack(alignment: .leading, spacing: 14) {
765
                                Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) {
766
                                    ForEach(projectionVariants) { variant in
767
                                        Text(variant.title).tag(variant.id)
768
                                    }
769
                                }
770
                                .pickerStyle(.menu)
771

            
772
                                if let selectedProjectionVariant {
773
                                    projectionVariantView(selectedProjectionVariant)
774
                                }
775
                            }
776
                        }
777
                    }
778
                }
779
                .padding()
780
                .padding(.top, 8)
781
            }
782
            .background(
783
                LinearGradient(
784
                    colors: [.teal.opacity(0.14), Color.clear],
785
                    startPoint: .topLeading,
786
                    endPoint: .bottomTrailing
787
                )
788
                .ignoresSafeArea()
789
            )
790
            .navigationTitle("Energy")
791
            .navigationBarTitleDisplayMode(.inline)
792
            .toolbar {
793
                ToolbarItem(placement: .cancellationAction) {
794
                    Button("Done") { visibility.toggle() }
795
                }
796
            }
797
        }
798
        .navigationViewStyle(StackNavigationViewStyle())
799
        .onAppear {
800
            updateSelectedProjectionMethod(with: projectionVariants)
801
        }
802
        .onChange(of: projectionVariantIDs) { _ in
803
            updateSelectedProjectionMethod(with: projectionVariants)
804
        }
805
    }
806

            
807
    private func projectionRow(title: String, value: String) -> some View {
808
        MeterInfoRowView(label: title, value: value)
809
    }
810

            
811
    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
812
        VStack(alignment: .leading, spacing: 8) {
813
            Text(variant.title)
814
                .font(.subheadline.weight(.semibold))
815

            
816
            projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration))
817
            projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy))
818
            projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower))
819
            projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy))
820
            projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy))
821
        }
822
        .padding(.bottom, 2)
823
    }
824

            
825
    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
826
        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
827
            return selectedVariant
828
        }
829

            
830
        return variants.last
831
    }
832

            
833
    private func selectedProjectionMethodBinding(
834
        for variants: [Measurements.EnergyProjectionVariant]
835
    ) -> Binding<String> {
836
        Binding(
837
            get: {
838
                resolvedProjectionVariant(from: variants)?.id ?? ""
839
            },
840
            set: { newValue in
841
                selectedProjectionMethodID = newValue
842
            }
843
        )
844
    }
845

            
846
    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
847
        guard !variants.isEmpty else {
848
            selectedProjectionMethodID = ""
849
            return
850
        }
851

            
852
        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
853
            return
854
        }
855

            
856
        selectedProjectionMethodID = variants.last?.id ?? ""
857
    }
858

            
859
    private func observedIntervalText(_ duration: TimeInterval) -> String {
860
        guard duration > 0 else { return "Insufficient data" }
861

            
862
        let totalSeconds = Int(duration.rounded())
863
        let hours = totalSeconds / 3600
864
        let minutes = (totalSeconds % 3600) / 60
865
        let seconds = totalSeconds % 60
866

            
867
        if hours > 0 {
868
            return "\(hours)h \(minutes)m"
869
        }
870

            
871
        if minutes > 0 {
872
            return "\(minutes)m \(seconds)s"
873
        }
874

            
875
        return "\(seconds)s"
876
    }
877

            
878
    private func averagePowerText(_ averagePower: Double?) -> String {
879
        guard let averagePower, averagePower.isFinite else {
880
            return "Insufficient data"
881
        }
882

            
883
        return "\(averagePower.format(decimalDigits: 3)) W"
884
    }
885

            
886
    private func averagePowerText(_ averagePower: Double) -> String {
887
        averagePowerText(Optional(averagePower))
888
    }
889

            
890
    private func energyText(_ energy: Double) -> String {
891
        if energy >= 1000 {
892
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
893
        }
894

            
895
        return "\(energy.format(decimalDigits: 3)) Wh"
896
    }
897

            
898
    private func projectedEnergyText(_ energy: Double?) -> String {
899
        guard let energy, energy.isFinite else {
900
            return "Insufficient data"
901
        }
902

            
903
        if energy >= 1000 {
904
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
905
        }
906

            
907
        return "\(energy.format(decimalDigits: 1)) Wh"
908
    }
909
}