Newer Older
905 lines | 36.971kb
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 2 months ago
12
    @EnvironmentObject private var meter: Meter
Bogdan Timofte authored 2 months ago
13
    @State private var powerAverageSheetVisibility = false
Bogdan Timofte authored a month ago
14
    @State private var energyProjectionSheetVisibility = false
Bogdan Timofte authored 2 months ago
15
    @State private var rssiHistorySheetVisibility = false
Bogdan Timofte authored 2 months ago
16
    var compactLayout: Bool = false
17
    var availableSize: CGSize? = nil
Bogdan Timofte authored 2 months ago
18

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

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

            
Bogdan Timofte authored 2 months ago
38
            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
Bogdan Timofte authored 2 months ago
39
                if shouldShowVoltageCard {
40
                    liveMetricCard(
41
                        title: "Voltage",
42
                        symbol: "bolt.fill",
43
                        color: .green,
44
                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
45
                        range: metricRange(
46
                            min: meter.measurements.voltage.context.minValue,
47
                            max: meter.measurements.voltage.context.maxValue,
48
                            unit: "V"
49
                        )
Bogdan Timofte authored 2 months ago
50
                    )
Bogdan Timofte authored 2 months ago
51
                }
Bogdan Timofte authored 2 months ago
52

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
226
    private var shouldShowMetricRange: Bool {
227
        !compactLayout || showsCompactMetricRange
228
    }
229

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

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

            
263
                Spacer(minLength: 0)
264
            }
Bogdan Timofte authored 2 months ago
265

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

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

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

            
306
        return AnyView(cardContent)
Bogdan Timofte authored 2 months ago
307
    }
308

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

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

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

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

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

            
345
        let unitSuffix = temperatureUnitSuffix()
Bogdan Timofte authored 2 months ago
346

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

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

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

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

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

            
383
private struct PowerAverageSheetView: View {
384
    @EnvironmentObject private var measurements: Measurements
385

            
386
    @Binding var visibility: Bool
387

            
388
    @State private var selectedSampleCount: Int = 20
389

            
390
    var body: some View {
391
        let bufferedSamples = measurements.powerSampleCount()
392

            
393
        NavigationView {
394
            ScrollView {
395
                VStack(alignment: .leading, spacing: 14) {
396
                    VStack(alignment: .leading, spacing: 8) {
397
                        Text("Power Average")
398
                            .font(.system(.title3, design: .rounded).weight(.bold))
399
                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
400
                            .font(.footnote)
401
                            .foregroundColor(.secondary)
402
                    }
403
                    .padding(18)
404
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
405

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

            
417
                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
418
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
419
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
420
                                        }
421
                                    }
422
                                    .pickerStyle(.menu)
423
                                }
424

            
425
                                VStack(alignment: .leading, spacing: 6) {
426
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
427
                                        .font(.system(.title2, design: .rounded).weight(.bold))
428
                                        .monospacedDigit()
429

            
430
                                    Text("Buffered samples: \(bufferedSamples)")
431
                                        .font(.caption)
432
                                        .foregroundColor(.secondary)
433
                                }
434
                            }
435
                        }
436
                    }
437

            
438
                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
Bogdan Timofte authored 2 months ago
439
                        Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
Bogdan Timofte authored 2 months ago
440
                            .font(.footnote)
441
                            .foregroundColor(.secondary)
442

            
443
                        Button("Reset Buffer") {
444
                            measurements.resetSeries()
445
                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
446
                        }
447
                        .foregroundColor(.red)
448
                    }
449
                }
450
                .padding()
451
            }
452
            .background(
453
                LinearGradient(
454
                    colors: [.pink.opacity(0.14), Color.clear],
455
                    startPoint: .topLeading,
456
                    endPoint: .bottomTrailing
457
                )
458
                .ignoresSafeArea()
459
            )
460
            .navigationBarItems(
461
                leading: Button("Done") { visibility.toggle() }
462
            )
463
            .navigationBarTitle("Power", displayMode: .inline)
464
        }
465
        .navigationViewStyle(StackNavigationViewStyle())
466
        .onAppear {
467
            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
468
        }
469
        .onChange(of: bufferedSamples) { newValue in
470
            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
471
        }
472
    }
473

            
474
    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
475
        guard bufferedSamples > 0 else { return [] }
476

            
477
        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
478
        return (filtered + [bufferedSamples]).sorted()
479
    }
480

            
481
    private func defaultSampleCount(bufferedSamples: Int) -> Int {
482
        guard bufferedSamples > 0 else { return 20 }
483
        return min(20, bufferedSamples)
484
    }
485

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

            
502
    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
503
        if option == bufferedSamples {
504
            return "All (\(option))"
505
        }
506
        return "\(option) values"
507
    }
508

            
509
    private func averagePowerLabel(bufferedSamples: Int) -> String {
510
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
511
            return "No data"
512
        }
513

            
514
        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
515
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
516
    }
517
}
518

            
519
private struct RSSIHistorySheetView: View {
520
    @EnvironmentObject private var measurements: Measurements
521

            
522
    @Binding var visibility: Bool
523

            
524
    private let xLabels: Int = 4
525
    private let yLabels: Int = 4
526

            
527
    var body: some View {
528
        let points = measurements.rssi.points
529
        let samplePoints = measurements.rssi.samplePoints
530
        let chartContext = buildChartContext(for: samplePoints)
531

            
532
        NavigationView {
533
            ScrollView {
534
                VStack(alignment: .leading, spacing: 14) {
535
                    VStack(alignment: .leading, spacing: 8) {
536
                        Text("RSSI History")
537
                            .font(.system(.title3, design: .rounded).weight(.bold))
538
                        Text("Signal strength captured over time while the meter stays connected.")
539
                            .font(.footnote)
540
                            .foregroundColor(.secondary)
541
                    }
542
                    .padding(18)
543
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
544

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

            
560
                                HStack(spacing: 8) {
561
                                    rssiYAxisView(context: chartContext)
562
                                        .frame(width: 52, height: 220)
563

            
564
                                    ZStack {
565
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
566
                                            .fill(Color.primary.opacity(0.05))
567

            
568
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
569
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
570

            
571
                                        rssiHorizontalGuides(context: chartContext)
572
                                        rssiVerticalGuides(context: chartContext)
573
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
574
                                            .opacity(0.82)
575
                                    }
576
                                    .frame(maxWidth: .infinity)
577
                                    .frame(height: 220)
578
                                }
579

            
580
                                rssiXAxisLabelsView(context: chartContext)
581
                                    .frame(height: 28)
582
                            }
583
                        }
584
                    }
585
                }
586
                .padding()
587
            }
588
            .background(
589
                LinearGradient(
590
                    colors: [.mint.opacity(0.14), Color.clear],
591
                    startPoint: .topLeading,
592
                    endPoint: .bottomTrailing
593
                )
594
                .ignoresSafeArea()
595
            )
596
            .navigationBarItems(
597
                leading: Button("Done") { visibility.toggle() }
598
            )
599
            .navigationBarTitle("RSSI", displayMode: .inline)
600
        }
601
        .navigationViewStyle(StackNavigationViewStyle())
602
    }
603

            
604
    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
605
        let context = ChartContext()
606
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
607
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
608
        let minimumValue = samplePoints.map(\.value).min() ?? -100
609
        let maximumValue = samplePoints.map(\.value).max() ?? -40
610
        let padding = max((maximumValue - minimumValue) * 0.12, 4)
611

            
612
        context.setBounds(
613
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
614
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
615
            yMin: CGFloat(minimumValue - padding),
616
            yMax: CGFloat(maximumValue + padding)
617
        )
618
        return context
619
    }
620

            
621
    private func signalSummaryChip(title: String, value: String) -> some View {
622
        VStack(alignment: .leading, spacing: 4) {
623
            Text(title)
624
                .font(.caption.weight(.semibold))
625
                .foregroundColor(.secondary)
626
            Text(value)
627
                .font(.subheadline.weight(.bold))
628
                .monospacedDigit()
629
        }
630
        .padding(.horizontal, 12)
631
        .padding(.vertical, 10)
632
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
633
    }
634

            
635
    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
636
        let labels = (1...xLabels).map {
637
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
638
        }
639

            
640
        return HStack {
641
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
642
                Text(item.element)
643
                    .font(.caption2.weight(.semibold))
644
                    .monospacedDigit()
645
                    .frame(maxWidth: .infinity)
646
            }
647
        }
648
        .foregroundColor(.secondary)
649
    }
650

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

            
673
    private func rssiHorizontalGuides(context: ChartContext) -> some View {
674
        GeometryReader { geometry in
675
            Path { path in
676
                for labelIndex in 1...yLabels {
677
                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
678
                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
679
                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
680
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
681
                }
682
            }
683
            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
684
        }
685
    }
686

            
687
    private func rssiVerticalGuides(context: ChartContext) -> some View {
688
        GeometryReader { geometry in
689
            Path { path in
690
                for labelIndex in 2..<xLabels {
691
                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
692
                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
693
                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
694
                    path.move(to: CGPoint(x: x, y: 0))
695
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
696
                }
697
            }
698
            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
699
        }
700
    }
Bogdan Timofte authored 2 months ago
701
}
Bogdan Timofte authored a month ago
702

            
703
private struct EnergyProjectionSheetView: View {
704
    @EnvironmentObject private var measurements: Measurements
705
    @EnvironmentObject private var meter: Meter
706

            
707
    @Binding var visibility: Bool
708
    @State private var selectedProjectionMethodID: String = ""
709

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

            
716
        NavigationView {
717
            ScrollView {
718
                VStack(alignment: .leading, spacing: 14) {
719
                    VStack(alignment: .leading, spacing: 8) {
720
                        Text("Energy Projections")
721
                            .font(.system(.title3, design: .rounded).weight(.bold))
722
                        Text("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
                            .font(.footnote)
724
                            .foregroundColor(.secondary)
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

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

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

            
803
    private func projectionRow(title: String, value: String) -> some View {
804
        MeterInfoRowView(label: title, value: value)
805
    }
806

            
807
    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
808
        VStack(alignment: .leading, spacing: 8) {
809
            Text(variant.title)
810
                .font(.subheadline.weight(.semibold))
811

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

            
821
    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
822
        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
823
            return selectedVariant
824
        }
825

            
826
        return variants.last
827
    }
828

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

            
842
    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
843
        guard !variants.isEmpty else {
844
            selectedProjectionMethodID = ""
845
            return
846
        }
847

            
848
        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
849
            return
850
        }
851

            
852
        selectedProjectionMethodID = variants.last?.id ?? ""
853
    }
854

            
855
    private func observedIntervalText(_ duration: TimeInterval) -> String {
856
        guard duration > 0 else { return "Insufficient data" }
857

            
858
        let totalSeconds = Int(duration.rounded())
859
        let hours = totalSeconds / 3600
860
        let minutes = (totalSeconds % 3600) / 60
861
        let seconds = totalSeconds % 60
862

            
863
        if hours > 0 {
864
            return "\(hours)h \(minutes)m"
865
        }
866

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

            
871
        return "\(seconds)s"
872
    }
873

            
874
    private func averagePowerText(_ averagePower: Double?) -> String {
875
        guard let averagePower, averagePower.isFinite else {
876
            return "Insufficient data"
877
        }
878

            
879
        return "\(averagePower.format(decimalDigits: 3)) W"
880
    }
881

            
882
    private func averagePowerText(_ averagePower: Double) -> String {
883
        averagePowerText(Optional(averagePower))
884
    }
885

            
886
    private func energyText(_ energy: Double) -> String {
887
        if energy >= 1000 {
888
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
889
        }
890

            
891
        return "\(energy.format(decimalDigits: 3)) Wh"
892
    }
893

            
894
    private func projectedEnergyText(_ energy: Double?) -> String {
895
        guard let energy, energy.isFinite else {
896
            return "Insufficient data"
897
        }
898

            
899
        if energy >= 1000 {
900
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
901
        }
902

            
903
        return "\(energy.format(decimalDigits: 1)) Wh"
904
    }
905
}