Newer Older
918 lines | 37.46kb
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) {
Bogdan Timofte authored a month ago
397
                        HStack(spacing: 8) {
398
                            Text("Power Average")
399
                                .font(.system(.title3, design: .rounded).weight(.bold))
400
                            ContextInfoButton(
401
                                title: "Power Average",
402
                                message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window."
403
                            )
404
                        }
Bogdan Timofte authored 2 months ago
405
                    }
406
                    .padding(18)
407
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
408

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

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

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

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

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

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

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

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

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

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

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

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

            
522
private struct RSSIHistorySheetView: View {
523
    @EnvironmentObject private var measurements: Measurements
524

            
525
    @Binding var visibility: Bool
526

            
527
    private let xLabels: Int = 4
528
    private let yLabels: Int = 4
529

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

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

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

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

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

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

            
577
                                        rssiHorizontalGuides(context: chartContext)
578
                                        rssiVerticalGuides(context: chartContext)
579
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
580
                                            .opacity(0.82)
581
                                    }
582
                                    .frame(maxWidth: .infinity)
583
                                    .frame(height: 220)
584
                                }
585

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

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

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

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

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

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

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

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

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

            
709
private struct EnergyProjectionSheetView: View {
710
    @EnvironmentObject private var measurements: Measurements
711
    @EnvironmentObject private var meter: Meter
712

            
713
    @Binding var visibility: Bool
714
    @State private var selectedProjectionMethodID: String = ""
715

            
716
    var body: some View {
717
        let snapshot = measurements.energyProjectionSnapshot()
718
        let projectionVariants = measurements.energyProjectionVariants()
719
        let projectionVariantIDs = projectionVariants.map(\.id)
720
        let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants)
721

            
722
        NavigationView {
723
            ScrollView {
724
                VStack(alignment: .leading, spacing: 14) {
725
                    VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored a month ago
726
                        HStack(spacing: 8) {
727
                            Text("Energy Projections")
728
                                .font(.system(.title3, design: .rounded).weight(.bold))
729
                            ContextInfoButton(
730
                                title: "Energy Projections",
731
                                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."
732
                            )
733
                        }
Bogdan Timofte authored a month ago
734
                    }
735
                    .padding(18)
736
                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
737

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

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

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

            
816
    private func projectionRow(title: String, value: String) -> some View {
817
        MeterInfoRowView(label: title, value: value)
818
    }
819

            
820
    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
821
        VStack(alignment: .leading, spacing: 8) {
822
            Text(variant.title)
823
                .font(.subheadline.weight(.semibold))
824

            
825
            projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration))
826
            projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy))
827
            projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower))
828
            projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy))
829
            projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy))
830
        }
831
        .padding(.bottom, 2)
832
    }
833

            
834
    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
835
        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
836
            return selectedVariant
837
        }
838

            
839
        return variants.last
840
    }
841

            
842
    private func selectedProjectionMethodBinding(
843
        for variants: [Measurements.EnergyProjectionVariant]
844
    ) -> Binding<String> {
845
        Binding(
846
            get: {
847
                resolvedProjectionVariant(from: variants)?.id ?? ""
848
            },
849
            set: { newValue in
850
                selectedProjectionMethodID = newValue
851
            }
852
        )
853
    }
854

            
855
    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
856
        guard !variants.isEmpty else {
857
            selectedProjectionMethodID = ""
858
            return
859
        }
860

            
861
        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
862
            return
863
        }
864

            
865
        selectedProjectionMethodID = variants.last?.id ?? ""
866
    }
867

            
868
    private func observedIntervalText(_ duration: TimeInterval) -> String {
869
        guard duration > 0 else { return "Insufficient data" }
870

            
871
        let totalSeconds = Int(duration.rounded())
872
        let hours = totalSeconds / 3600
873
        let minutes = (totalSeconds % 3600) / 60
874
        let seconds = totalSeconds % 60
875

            
876
        if hours > 0 {
877
            return "\(hours)h \(minutes)m"
878
        }
879

            
880
        if minutes > 0 {
881
            return "\(minutes)m \(seconds)s"
882
        }
883

            
884
        return "\(seconds)s"
885
    }
886

            
887
    private func averagePowerText(_ averagePower: Double?) -> String {
888
        guard let averagePower, averagePower.isFinite else {
889
            return "Insufficient data"
890
        }
891

            
892
        return "\(averagePower.format(decimalDigits: 3)) W"
893
    }
894

            
895
    private func averagePowerText(_ averagePower: Double) -> String {
896
        averagePowerText(Optional(averagePower))
897
    }
898

            
899
    private func energyText(_ energy: Double) -> String {
900
        if energy >= 1000 {
901
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
902
        }
903

            
904
        return "\(energy.format(decimalDigits: 3)) Wh"
905
    }
906

            
907
    private func projectedEnergyText(_ energy: Double?) -> String {
908
        guard let energy, energy.isFinite else {
909
            return "Insufficient data"
910
        }
911

            
912
        if energy >= 1000 {
913
            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
914
        }
915

            
916
        return "\(energy.format(decimalDigits: 1)) Wh"
917
    }
918
}