Newer Older
c34c6eb 17 hours ago History
692 lines | 28.246kb
Bogdan Timofte authored 2 weeks ago
1
//
Bogdan Timofte authored a week ago
2
//  MeterLiveContentView.swift
Bogdan Timofte authored 2 weeks 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 a week ago
11
struct MeterLiveContentView: View {
Bogdan Timofte authored 2 weeks ago
12
    @EnvironmentObject private var meter: Meter
Bogdan Timofte authored 4 days ago
13
    @State private var powerAverageSheetVisibility = false
14
    @State private var rssiHistorySheetVisibility = false
Bogdan Timofte authored 2 weeks ago
15
    var compactLayout: Bool = false
16
    var availableSize: CGSize? = nil
Bogdan Timofte authored 2 weeks ago
17

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

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

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

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

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

            
Bogdan Timofte authored 17 hours ago
83
                if shouldShowEnergyCard {
84
                    liveMetricCard(
85
                        title: "Energy",
86
                        symbol: "battery.100.bolt",
87
                        color: .teal,
88
                        value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
89
                        detailText: "Buffered accumulated energy"
90
                    )
91
                }
92

            
Bogdan Timofte authored 6 days ago
93
                if shouldShowTemperatureCard {
94
                    liveMetricCard(
95
                        title: "Temperature",
96
                        symbol: "thermometer.medium",
97
                        color: .orange,
98
                        value: meter.primaryTemperatureDescription,
Bogdan Timofte authored 4 days ago
99
                        range: temperatureRange(
100
                            min: meter.measurements.temperature.context.minValue,
101
                            max: meter.measurements.temperature.context.maxValue
102
                        )
Bogdan Timofte authored 6 days ago
103
                    )
104
                }
Bogdan Timofte authored 2 weeks ago
105

            
Bogdan Timofte authored 6 days ago
106
                if shouldShowLoadCard {
107
                    liveMetricCard(
108
                        title: "Load",
109
                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
110
                        color: .yellow,
111
                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
112
                        detailText: "Measured resistance"
113
                    )
114
                }
Bogdan Timofte authored 2 weeks ago
115

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

            
Bogdan Timofte authored 6 days ago
133
                if meter.supportsChargerDetection && hasLiveMetrics {
Bogdan Timofte authored a week ago
134
                    liveMetricCard(
135
                        title: "Detected Charger",
136
                        symbol: "powerplug.fill",
137
                        color: .indigo,
138
                        value: meter.chargerTypeDescription,
139
                        detailText: "Source handshake",
140
                        valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
141
                        valueLineLimit: 2,
142
                        valueMonospacedDigits: false,
143
                        valueMinimumScaleFactor: 0.72
144
                    )
145
                }
Bogdan Timofte authored 2 weeks ago
146
            }
147
        }
Bogdan Timofte authored 2 weeks ago
148
        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 4 days ago
149
        .sheet(isPresented: $powerAverageSheetVisibility) {
150
            PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
151
                .environmentObject(meter.measurements)
152
        }
153
        .sheet(isPresented: $rssiHistorySheetVisibility) {
154
            RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
155
                .environmentObject(meter.measurements)
156
        }
Bogdan Timofte authored 2 weeks ago
157
    }
158

            
Bogdan Timofte authored 6 days ago
159
    private var hasLiveMetrics: Bool {
160
        meter.operationalState == .dataIsAvailable
161
    }
162

            
163
    private var shouldShowVoltageCard: Bool {
164
        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
165
    }
166

            
167
    private var shouldShowCurrentCard: Bool {
168
        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
169
    }
170

            
171
    private var shouldShowPowerCard: Bool {
172
        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
173
    }
174

            
Bogdan Timofte authored 17 hours ago
175
    private var shouldShowEnergyCard: Bool {
176
        hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite
177
    }
178

            
Bogdan Timofte authored 6 days ago
179
    private var shouldShowTemperatureCard: Bool {
180
        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
181
    }
182

            
Bogdan Timofte authored 17 hours ago
183
    private var liveBufferedEnergyValue: Double {
184
        meter.measurements.energy.samplePoints.last?.value ?? 0
185
    }
186

            
Bogdan Timofte authored 6 days ago
187
    private var shouldShowLoadCard: Bool {
188
        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
189
    }
190

            
Bogdan Timofte authored 2 weeks ago
191
    private var liveMetricColumns: [GridItem] {
192
        if compactLayout {
Bogdan Timofte authored 2 weeks ago
193
            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
Bogdan Timofte authored 2 weeks ago
194
        }
195

            
196
        return [GridItem(.flexible()), GridItem(.flexible())]
Bogdan Timofte authored 2 weeks ago
197
    }
Bogdan Timofte authored 2 weeks ago
198

            
199
    private var statusBadge: some View {
200
        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
201
            .font(.caption.weight(.semibold))
202
            .padding(.horizontal, 10)
203
            .padding(.vertical, 6)
204
            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
205
            .meterCard(
206
                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
207
                fillOpacity: 0.12,
208
                strokeOpacity: 0.16,
209
                cornerRadius: 999
210
            )
211
    }
212

            
Bogdan Timofte authored 2 weeks ago
213
    private var showsCompactMetricRange: Bool {
214
        compactLayout && (availableSize?.height ?? 0) >= 380
215
    }
216

            
217
    private var shouldShowMetricRange: Bool {
218
        !compactLayout || showsCompactMetricRange
219
    }
220

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

            
Bogdan Timofte authored 2 weeks ago
249
                Text(title)
250
                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
251
                    .foregroundColor(.secondary)
252
                    .lineLimit(1)
253

            
254
                Spacer(minLength: 0)
255
            }
Bogdan Timofte authored 2 weeks ago
256

            
Bogdan Timofte authored 2 weeks ago
257
            Group {
258
                if valueMonospacedDigits {
259
                    Text(value)
260
                        .monospacedDigit()
261
                } else {
262
                    Text(value)
263
                }
264
            }
265
            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
266
            .lineLimit(valueLineLimit)
267
            .minimumScaleFactor(valueMinimumScaleFactor)
Bogdan Timofte authored 2 weeks ago
268

            
Bogdan Timofte authored 2 weeks ago
269
            if shouldShowMetricRange {
270
                if let range {
271
                    metricRangeTable(range)
272
                } else if let detailText, !detailText.isEmpty {
273
                    Text(detailText)
274
                        .font(.caption)
275
                        .foregroundColor(.secondary)
276
                        .lineLimit(2)
277
                }
Bogdan Timofte authored 2 weeks ago
278
            }
279
        }
Bogdan Timofte authored 2 weeks ago
280
        .frame(
281
            maxWidth: .infinity,
Bogdan Timofte authored 2 weeks ago
282
            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
Bogdan Timofte authored 2 weeks ago
283
            alignment: .leading
284
        )
285
        .padding(compactLayout ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
286
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
Bogdan Timofte authored 4 days ago
287

            
288
        if let action {
289
            return AnyView(
290
                Button(action: action) {
291
                    cardContent
292
                }
293
                .buttonStyle(.plain)
294
            )
295
        }
296

            
297
        return AnyView(cardContent)
Bogdan Timofte authored 2 weeks ago
298
    }
299

            
Bogdan Timofte authored a week ago
300
    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
Bogdan Timofte authored 2 weeks ago
301
        VStack(alignment: .leading, spacing: 4) {
302
            HStack(spacing: 12) {
303
                Text(range.minLabel)
304
                Spacer(minLength: 0)
305
                Text(range.maxLabel)
306
            }
307
            .font(.caption2.weight(.semibold))
308
            .foregroundColor(.secondary)
309

            
310
            HStack(spacing: 12) {
311
                Text(range.minValue)
312
                    .monospacedDigit()
313
                Spacer(minLength: 0)
314
                Text(range.maxValue)
315
                    .monospacedDigit()
316
            }
317
            .font(.caption.weight(.medium))
318
            .foregroundColor(.primary)
319
        }
320
    }
321

            
Bogdan Timofte authored 4 days ago
322
    private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
Bogdan Timofte authored 2 weeks ago
323
        guard min.isFinite, max.isFinite else { return nil }
Bogdan Timofte authored 2 weeks ago
324

            
Bogdan Timofte authored a week ago
325
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 weeks ago
326
            minLabel: "Min",
327
            maxLabel: "Max",
Bogdan Timofte authored 4 days ago
328
            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
329
            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
Bogdan Timofte authored 2 weeks ago
330
        )
331
    }
332

            
Bogdan Timofte authored 4 days ago
333
    private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
334
        guard min.isFinite, max.isFinite else { return nil }
335

            
336
        let unitSuffix = temperatureUnitSuffix()
Bogdan Timofte authored 2 weeks ago
337

            
Bogdan Timofte authored a week ago
338
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 weeks ago
339
            minLabel: "Min",
340
            maxLabel: "Max",
Bogdan Timofte authored 4 days ago
341
            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
342
            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
Bogdan Timofte authored 2 weeks ago
343
        )
Bogdan Timofte authored 2 weeks ago
344
    }
Bogdan Timofte authored a week ago
345

            
346
    private func meterHistoryText(for date: Date?) -> String {
347
        guard let date else {
348
            return "Never"
349
        }
350
        return date.format(as: "yyyy-MM-dd HH:mm")
351
    }
Bogdan Timofte authored 4 days ago
352

            
353
    private func temperatureUnitSuffix() -> String {
354
        if meter.supportsManualTemperatureUnitSelection {
355
            return "°"
356
        }
357

            
358
        let locale = Locale.autoupdatingCurrent
359
        if #available(iOS 16.0, *) {
360
            switch locale.measurementSystem {
361
            case .us:
362
                return "°F"
363
            default:
364
                return "°C"
365
            }
366
        }
367

            
368
        let regionCode = locale.regionCode ?? ""
369
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
370
        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
371
    }
372
}
373

            
374
private struct PowerAverageSheetView: View {
375
    @EnvironmentObject private var measurements: Measurements
376

            
377
    @Binding var visibility: Bool
378

            
379
    @State private var selectedSampleCount: Int = 20
380

            
381
    var body: some View {
382
        let bufferedSamples = measurements.powerSampleCount()
383

            
384
        NavigationView {
385
            ScrollView {
386
                VStack(alignment: .leading, spacing: 14) {
387
                    VStack(alignment: .leading, spacing: 8) {
388
                        Text("Power Average")
389
                            .font(.system(.title3, design: .rounded).weight(.bold))
390
                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
391
                            .font(.footnote)
392
                            .foregroundColor(.secondary)
393
                    }
394
                    .padding(18)
395
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
396

            
397
                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
398
                        if bufferedSamples == 0 {
399
                            Text("No power samples are available yet.")
400
                                .font(.footnote)
401
                                .foregroundColor(.secondary)
402
                        } else {
403
                            VStack(alignment: .leading, spacing: 14) {
404
                                VStack(alignment: .leading, spacing: 8) {
405
                                    Text("Values used")
406
                                        .font(.subheadline.weight(.semibold))
407

            
408
                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
409
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
410
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
411
                                        }
412
                                    }
413
                                    .pickerStyle(.menu)
414
                                }
415

            
416
                                VStack(alignment: .leading, spacing: 6) {
417
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
418
                                        .font(.system(.title2, design: .rounded).weight(.bold))
419
                                        .monospacedDigit()
420

            
421
                                    Text("Buffered samples: \(bufferedSamples)")
422
                                        .font(.caption)
423
                                        .foregroundColor(.secondary)
424
                                }
425
                            }
426
                        }
427
                    }
428

            
429
                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
Bogdan Timofte authored 17 hours ago
430
                        Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
Bogdan Timofte authored 4 days ago
431
                            .font(.footnote)
432
                            .foregroundColor(.secondary)
433

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

            
465
    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
466
        guard bufferedSamples > 0 else { return [] }
467

            
468
        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
469
        return (filtered + [bufferedSamples]).sorted()
470
    }
471

            
472
    private func defaultSampleCount(bufferedSamples: Int) -> Int {
473
        guard bufferedSamples > 0 else { return 20 }
474
        return min(20, bufferedSamples)
475
    }
476

            
477
    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
478
        Binding(
479
            get: {
480
                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
481
                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
482
                if availableOptions.contains(selectedSampleCount) {
483
                    return selectedSampleCount
484
                }
485
                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
486
            },
487
            set: { newValue in
488
                selectedSampleCount = newValue
489
            }
490
        )
491
    }
492

            
493
    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
494
        if option == bufferedSamples {
495
            return "All (\(option))"
496
        }
497
        return "\(option) values"
498
    }
499

            
500
    private func averagePowerLabel(bufferedSamples: Int) -> String {
501
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
502
            return "No data"
503
        }
504

            
505
        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
506
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
507
    }
508
}
509

            
510
private struct RSSIHistorySheetView: View {
511
    @EnvironmentObject private var measurements: Measurements
512

            
513
    @Binding var visibility: Bool
514

            
515
    private let xLabels: Int = 4
516
    private let yLabels: Int = 4
517

            
518
    var body: some View {
519
        let points = measurements.rssi.points
520
        let samplePoints = measurements.rssi.samplePoints
521
        let chartContext = buildChartContext(for: samplePoints)
522

            
523
        NavigationView {
524
            ScrollView {
525
                VStack(alignment: .leading, spacing: 14) {
526
                    VStack(alignment: .leading, spacing: 8) {
527
                        Text("RSSI History")
528
                            .font(.system(.title3, design: .rounded).weight(.bold))
529
                        Text("Signal strength captured over time while the meter stays connected.")
530
                            .font(.footnote)
531
                            .foregroundColor(.secondary)
532
                    }
533
                    .padding(18)
534
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
535

            
536
                    if samplePoints.isEmpty {
537
                        Text("No RSSI samples have been captured yet.")
538
                            .font(.footnote)
539
                            .foregroundColor(.secondary)
540
                            .padding(18)
541
                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
542
                    } else {
543
                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
544
                            VStack(alignment: .leading, spacing: 12) {
545
                                HStack(spacing: 12) {
546
                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
547
                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
548
                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
549
                                }
550

            
551
                                HStack(spacing: 8) {
552
                                    rssiYAxisView(context: chartContext)
553
                                        .frame(width: 52, height: 220)
554

            
555
                                    ZStack {
556
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
557
                                            .fill(Color.primary.opacity(0.05))
558

            
559
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
560
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
561

            
562
                                        rssiHorizontalGuides(context: chartContext)
563
                                        rssiVerticalGuides(context: chartContext)
564
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
565
                                            .opacity(0.82)
566
                                    }
567
                                    .frame(maxWidth: .infinity)
568
                                    .frame(height: 220)
569
                                }
570

            
571
                                rssiXAxisLabelsView(context: chartContext)
572
                                    .frame(height: 28)
573
                            }
574
                        }
575
                    }
576
                }
577
                .padding()
578
            }
579
            .background(
580
                LinearGradient(
581
                    colors: [.mint.opacity(0.14), Color.clear],
582
                    startPoint: .topLeading,
583
                    endPoint: .bottomTrailing
584
                )
585
                .ignoresSafeArea()
586
            )
587
            .navigationBarItems(
588
                leading: Button("Done") { visibility.toggle() }
589
            )
590
            .navigationBarTitle("RSSI", displayMode: .inline)
591
        }
592
        .navigationViewStyle(StackNavigationViewStyle())
593
    }
594

            
595
    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
596
        let context = ChartContext()
597
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
598
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
599
        let minimumValue = samplePoints.map(\.value).min() ?? -100
600
        let maximumValue = samplePoints.map(\.value).max() ?? -40
601
        let padding = max((maximumValue - minimumValue) * 0.12, 4)
602

            
603
        context.setBounds(
604
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
605
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
606
            yMin: CGFloat(minimumValue - padding),
607
            yMax: CGFloat(maximumValue + padding)
608
        )
609
        return context
610
    }
611

            
612
    private func signalSummaryChip(title: String, value: String) -> some View {
613
        VStack(alignment: .leading, spacing: 4) {
614
            Text(title)
615
                .font(.caption.weight(.semibold))
616
                .foregroundColor(.secondary)
617
            Text(value)
618
                .font(.subheadline.weight(.bold))
619
                .monospacedDigit()
620
        }
621
        .padding(.horizontal, 12)
622
        .padding(.vertical, 10)
623
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
624
    }
625

            
626
    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
627
        let labels = (1...xLabels).map {
628
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
629
        }
630

            
631
        return HStack {
632
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
633
                Text(item.element)
634
                    .font(.caption2.weight(.semibold))
635
                    .monospacedDigit()
636
                    .frame(maxWidth: .infinity)
637
            }
638
        }
639
        .foregroundColor(.secondary)
640
    }
641

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

            
664
    private func rssiHorizontalGuides(context: ChartContext) -> some View {
665
        GeometryReader { geometry in
666
            Path { path in
667
                for labelIndex in 1...yLabels {
668
                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
669
                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
670
                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
671
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
672
                }
673
            }
674
            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
675
        }
676
    }
677

            
678
    private func rssiVerticalGuides(context: ChartContext) -> some View {
679
        GeometryReader { geometry in
680
            Path { path in
681
                for labelIndex in 2..<xLabels {
682
                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
683
                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
684
                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
685
                    path.move(to: CGPoint(x: x, y: 0))
686
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
687
                }
688
            }
689
            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
690
        }
691
    }
Bogdan Timofte authored 2 weeks ago
692
}