Newer Older
674 lines | 27.556kb
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 6 days ago
83
                if shouldShowTemperatureCard {
84
                    liveMetricCard(
85
                        title: "Temperature",
86
                        symbol: "thermometer.medium",
87
                        color: .orange,
88
                        value: meter.primaryTemperatureDescription,
Bogdan Timofte authored 4 days ago
89
                        range: temperatureRange(
90
                            min: meter.measurements.temperature.context.minValue,
91
                            max: meter.measurements.temperature.context.maxValue
92
                        )
Bogdan Timofte authored 6 days ago
93
                    )
94
                }
Bogdan Timofte authored 2 weeks ago
95

            
Bogdan Timofte authored 6 days ago
96
                if shouldShowLoadCard {
97
                    liveMetricCard(
98
                        title: "Load",
99
                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
100
                        color: .yellow,
101
                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
102
                        detailText: "Measured resistance"
103
                    )
104
                }
Bogdan Timofte authored 2 weeks ago
105

            
Bogdan Timofte authored 2 weeks ago
106
                liveMetricCard(
107
                    title: "RSSI",
108
                    symbol: "dot.radiowaves.left.and.right",
109
                    color: .mint,
Bogdan Timofte authored 2 weeks ago
110
                    value: "\(meter.btSerial.averageRSSI) dBm",
Bogdan Timofte authored 4 days ago
111
                    range: metricRange(
112
                        min: meter.measurements.rssi.context.minValue,
113
                        max: meter.measurements.rssi.context.maxValue,
114
                        unit: "dBm",
115
                        decimalDigits: 0
Bogdan Timofte authored 2 weeks ago
116
                    ),
Bogdan Timofte authored 4 days ago
117
                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
118
                    action: {
119
                        rssiHistorySheetVisibility = true
120
                    }
Bogdan Timofte authored 2 weeks ago
121
                )
Bogdan Timofte authored a week ago
122

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

            
Bogdan Timofte authored 6 days ago
149
    private var hasLiveMetrics: Bool {
150
        meter.operationalState == .dataIsAvailable
151
    }
152

            
153
    private var shouldShowVoltageCard: Bool {
154
        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
155
    }
156

            
157
    private var shouldShowCurrentCard: Bool {
158
        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
159
    }
160

            
161
    private var shouldShowPowerCard: Bool {
162
        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
163
    }
164

            
165
    private var shouldShowTemperatureCard: Bool {
166
        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
167
    }
168

            
169
    private var shouldShowLoadCard: Bool {
170
        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
171
    }
172

            
Bogdan Timofte authored 2 weeks ago
173
    private var liveMetricColumns: [GridItem] {
174
        if compactLayout {
Bogdan Timofte authored 2 weeks ago
175
            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
Bogdan Timofte authored 2 weeks ago
176
        }
177

            
178
        return [GridItem(.flexible()), GridItem(.flexible())]
Bogdan Timofte authored 2 weeks ago
179
    }
Bogdan Timofte authored 2 weeks ago
180

            
181
    private var statusBadge: some View {
182
        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
183
            .font(.caption.weight(.semibold))
184
            .padding(.horizontal, 10)
185
            .padding(.vertical, 6)
186
            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
187
            .meterCard(
188
                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
189
                fillOpacity: 0.12,
190
                strokeOpacity: 0.16,
191
                cornerRadius: 999
192
            )
193
    }
194

            
Bogdan Timofte authored 2 weeks ago
195
    private var showsCompactMetricRange: Bool {
196
        compactLayout && (availableSize?.height ?? 0) >= 380
197
    }
198

            
199
    private var shouldShowMetricRange: Bool {
200
        !compactLayout || showsCompactMetricRange
201
    }
202

            
Bogdan Timofte authored 2 weeks ago
203
    private func liveMetricCard(
204
        title: String,
Bogdan Timofte authored 2 weeks ago
205
        symbol: String? = nil,
206
        customSymbol: AnyView? = nil,
Bogdan Timofte authored 2 weeks ago
207
        color: Color,
208
        value: String,
Bogdan Timofte authored a week ago
209
        range: MeterLiveMetricRange? = nil,
Bogdan Timofte authored 2 weeks ago
210
        detailText: String? = nil,
211
        valueFont: Font? = nil,
212
        valueLineLimit: Int = 1,
213
        valueMonospacedDigits: Bool = true,
Bogdan Timofte authored 4 days ago
214
        valueMinimumScaleFactor: CGFloat = 0.85,
215
        action: (() -> Void)? = nil
Bogdan Timofte authored 2 weeks ago
216
    ) -> some View {
Bogdan Timofte authored 4 days ago
217
        let cardContent = VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored 2 weeks ago
218
            HStack(spacing: compactLayout ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
219
                Group {
220
                    if let customSymbol {
221
                        customSymbol
222
                    } else if let symbol {
223
                        Image(systemName: symbol)
224
                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
225
                            .foregroundColor(color)
226
                    }
227
                }
Bogdan Timofte authored 2 weeks ago
228
                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
Bogdan Timofte authored 2 weeks ago
229
                .background(Circle().fill(color.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
230

            
Bogdan Timofte authored 2 weeks ago
231
                Text(title)
232
                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
233
                    .foregroundColor(.secondary)
234
                    .lineLimit(1)
235

            
236
                Spacer(minLength: 0)
237
            }
Bogdan Timofte authored 2 weeks ago
238

            
Bogdan Timofte authored 2 weeks ago
239
            Group {
240
                if valueMonospacedDigits {
241
                    Text(value)
242
                        .monospacedDigit()
243
                } else {
244
                    Text(value)
245
                }
246
            }
247
            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
248
            .lineLimit(valueLineLimit)
249
            .minimumScaleFactor(valueMinimumScaleFactor)
Bogdan Timofte authored 2 weeks ago
250

            
Bogdan Timofte authored 2 weeks ago
251
            if shouldShowMetricRange {
252
                if let range {
253
                    metricRangeTable(range)
254
                } else if let detailText, !detailText.isEmpty {
255
                    Text(detailText)
256
                        .font(.caption)
257
                        .foregroundColor(.secondary)
258
                        .lineLimit(2)
259
                }
Bogdan Timofte authored 2 weeks ago
260
            }
261
        }
Bogdan Timofte authored 2 weeks ago
262
        .frame(
263
            maxWidth: .infinity,
Bogdan Timofte authored 2 weeks ago
264
            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
Bogdan Timofte authored 2 weeks ago
265
            alignment: .leading
266
        )
267
        .padding(compactLayout ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
268
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
Bogdan Timofte authored 4 days ago
269

            
270
        if let action {
271
            return AnyView(
272
                Button(action: action) {
273
                    cardContent
274
                }
275
                .buttonStyle(.plain)
276
            )
277
        }
278

            
279
        return AnyView(cardContent)
Bogdan Timofte authored 2 weeks ago
280
    }
281

            
Bogdan Timofte authored a week ago
282
    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
Bogdan Timofte authored 2 weeks ago
283
        VStack(alignment: .leading, spacing: 4) {
284
            HStack(spacing: 12) {
285
                Text(range.minLabel)
286
                Spacer(minLength: 0)
287
                Text(range.maxLabel)
288
            }
289
            .font(.caption2.weight(.semibold))
290
            .foregroundColor(.secondary)
291

            
292
            HStack(spacing: 12) {
293
                Text(range.minValue)
294
                    .monospacedDigit()
295
                Spacer(minLength: 0)
296
                Text(range.maxValue)
297
                    .monospacedDigit()
298
            }
299
            .font(.caption.weight(.medium))
300
            .foregroundColor(.primary)
301
        }
302
    }
303

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

            
Bogdan Timofte authored a week ago
307
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 weeks ago
308
            minLabel: "Min",
309
            maxLabel: "Max",
Bogdan Timofte authored 4 days ago
310
            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
311
            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
Bogdan Timofte authored 2 weeks ago
312
        )
313
    }
314

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

            
318
        let unitSuffix = temperatureUnitSuffix()
Bogdan Timofte authored 2 weeks ago
319

            
Bogdan Timofte authored a week ago
320
        return MeterLiveMetricRange(
Bogdan Timofte authored 2 weeks ago
321
            minLabel: "Min",
322
            maxLabel: "Max",
Bogdan Timofte authored 4 days ago
323
            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
324
            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
Bogdan Timofte authored 2 weeks ago
325
        )
Bogdan Timofte authored 2 weeks ago
326
    }
Bogdan Timofte authored a week ago
327

            
328
    private func meterHistoryText(for date: Date?) -> String {
329
        guard let date else {
330
            return "Never"
331
        }
332
        return date.format(as: "yyyy-MM-dd HH:mm")
333
    }
Bogdan Timofte authored 4 days ago
334

            
335
    private func temperatureUnitSuffix() -> String {
336
        if meter.supportsManualTemperatureUnitSelection {
337
            return "°"
338
        }
339

            
340
        let locale = Locale.autoupdatingCurrent
341
        if #available(iOS 16.0, *) {
342
            switch locale.measurementSystem {
343
            case .us:
344
                return "°F"
345
            default:
346
                return "°C"
347
            }
348
        }
349

            
350
        let regionCode = locale.regionCode ?? ""
351
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
352
        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
353
    }
354
}
355

            
356
private struct PowerAverageSheetView: View {
357
    @EnvironmentObject private var measurements: Measurements
358

            
359
    @Binding var visibility: Bool
360

            
361
    @State private var selectedSampleCount: Int = 20
362

            
363
    var body: some View {
364
        let bufferedSamples = measurements.powerSampleCount()
365

            
366
        NavigationView {
367
            ScrollView {
368
                VStack(alignment: .leading, spacing: 14) {
369
                    VStack(alignment: .leading, spacing: 8) {
370
                        Text("Power Average")
371
                            .font(.system(.title3, design: .rounded).weight(.bold))
372
                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
373
                            .font(.footnote)
374
                            .foregroundColor(.secondary)
375
                    }
376
                    .padding(18)
377
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
378

            
379
                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
380
                        if bufferedSamples == 0 {
381
                            Text("No power samples are available yet.")
382
                                .font(.footnote)
383
                                .foregroundColor(.secondary)
384
                        } else {
385
                            VStack(alignment: .leading, spacing: 14) {
386
                                VStack(alignment: .leading, spacing: 8) {
387
                                    Text("Values used")
388
                                        .font(.subheadline.weight(.semibold))
389

            
390
                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
391
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
392
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
393
                                        }
394
                                    }
395
                                    .pickerStyle(.menu)
396
                                }
397

            
398
                                VStack(alignment: .leading, spacing: 6) {
399
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
400
                                        .font(.system(.title2, design: .rounded).weight(.bold))
401
                                        .monospacedDigit()
402

            
403
                                    Text("Buffered samples: \(bufferedSamples)")
404
                                        .font(.caption)
405
                                        .foregroundColor(.secondary)
406
                                }
407
                            }
408
                        }
409
                    }
410

            
411
                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
412
                        Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.")
413
                            .font(.footnote)
414
                            .foregroundColor(.secondary)
415

            
416
                        Button("Reset Buffer") {
417
                            measurements.resetSeries()
418
                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
419
                        }
420
                        .foregroundColor(.red)
421
                    }
422
                }
423
                .padding()
424
            }
425
            .background(
426
                LinearGradient(
427
                    colors: [.pink.opacity(0.14), Color.clear],
428
                    startPoint: .topLeading,
429
                    endPoint: .bottomTrailing
430
                )
431
                .ignoresSafeArea()
432
            )
433
            .navigationBarItems(
434
                leading: Button("Done") { visibility.toggle() }
435
            )
436
            .navigationBarTitle("Power", displayMode: .inline)
437
        }
438
        .navigationViewStyle(StackNavigationViewStyle())
439
        .onAppear {
440
            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
441
        }
442
        .onChange(of: bufferedSamples) { newValue in
443
            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
444
        }
445
    }
446

            
447
    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
448
        guard bufferedSamples > 0 else { return [] }
449

            
450
        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
451
        return (filtered + [bufferedSamples]).sorted()
452
    }
453

            
454
    private func defaultSampleCount(bufferedSamples: Int) -> Int {
455
        guard bufferedSamples > 0 else { return 20 }
456
        return min(20, bufferedSamples)
457
    }
458

            
459
    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
460
        Binding(
461
            get: {
462
                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
463
                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
464
                if availableOptions.contains(selectedSampleCount) {
465
                    return selectedSampleCount
466
                }
467
                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
468
            },
469
            set: { newValue in
470
                selectedSampleCount = newValue
471
            }
472
        )
473
    }
474

            
475
    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
476
        if option == bufferedSamples {
477
            return "All (\(option))"
478
        }
479
        return "\(option) values"
480
    }
481

            
482
    private func averagePowerLabel(bufferedSamples: Int) -> String {
483
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
484
            return "No data"
485
        }
486

            
487
        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
488
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
489
    }
490
}
491

            
492
private struct RSSIHistorySheetView: View {
493
    @EnvironmentObject private var measurements: Measurements
494

            
495
    @Binding var visibility: Bool
496

            
497
    private let xLabels: Int = 4
498
    private let yLabels: Int = 4
499

            
500
    var body: some View {
501
        let points = measurements.rssi.points
502
        let samplePoints = measurements.rssi.samplePoints
503
        let chartContext = buildChartContext(for: samplePoints)
504

            
505
        NavigationView {
506
            ScrollView {
507
                VStack(alignment: .leading, spacing: 14) {
508
                    VStack(alignment: .leading, spacing: 8) {
509
                        Text("RSSI History")
510
                            .font(.system(.title3, design: .rounded).weight(.bold))
511
                        Text("Signal strength captured over time while the meter stays connected.")
512
                            .font(.footnote)
513
                            .foregroundColor(.secondary)
514
                    }
515
                    .padding(18)
516
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
517

            
518
                    if samplePoints.isEmpty {
519
                        Text("No RSSI samples have been captured yet.")
520
                            .font(.footnote)
521
                            .foregroundColor(.secondary)
522
                            .padding(18)
523
                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
524
                    } else {
525
                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
526
                            VStack(alignment: .leading, spacing: 12) {
527
                                HStack(spacing: 12) {
528
                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
529
                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
530
                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
531
                                }
532

            
533
                                HStack(spacing: 8) {
534
                                    rssiYAxisView(context: chartContext)
535
                                        .frame(width: 52, height: 220)
536

            
537
                                    ZStack {
538
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
539
                                            .fill(Color.primary.opacity(0.05))
540

            
541
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
542
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
543

            
544
                                        rssiHorizontalGuides(context: chartContext)
545
                                        rssiVerticalGuides(context: chartContext)
546
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
547
                                            .opacity(0.82)
548
                                    }
549
                                    .frame(maxWidth: .infinity)
550
                                    .frame(height: 220)
551
                                }
552

            
553
                                rssiXAxisLabelsView(context: chartContext)
554
                                    .frame(height: 28)
555
                            }
556
                        }
557
                    }
558
                }
559
                .padding()
560
            }
561
            .background(
562
                LinearGradient(
563
                    colors: [.mint.opacity(0.14), Color.clear],
564
                    startPoint: .topLeading,
565
                    endPoint: .bottomTrailing
566
                )
567
                .ignoresSafeArea()
568
            )
569
            .navigationBarItems(
570
                leading: Button("Done") { visibility.toggle() }
571
            )
572
            .navigationBarTitle("RSSI", displayMode: .inline)
573
        }
574
        .navigationViewStyle(StackNavigationViewStyle())
575
    }
576

            
577
    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
578
        let context = ChartContext()
579
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
580
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
581
        let minimumValue = samplePoints.map(\.value).min() ?? -100
582
        let maximumValue = samplePoints.map(\.value).max() ?? -40
583
        let padding = max((maximumValue - minimumValue) * 0.12, 4)
584

            
585
        context.setBounds(
586
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
587
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
588
            yMin: CGFloat(minimumValue - padding),
589
            yMax: CGFloat(maximumValue + padding)
590
        )
591
        return context
592
    }
593

            
594
    private func signalSummaryChip(title: String, value: String) -> some View {
595
        VStack(alignment: .leading, spacing: 4) {
596
            Text(title)
597
                .font(.caption.weight(.semibold))
598
                .foregroundColor(.secondary)
599
            Text(value)
600
                .font(.subheadline.weight(.bold))
601
                .monospacedDigit()
602
        }
603
        .padding(.horizontal, 12)
604
        .padding(.vertical, 10)
605
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
606
    }
607

            
608
    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
609
        let labels = (1...xLabels).map {
610
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
611
        }
612

            
613
        return HStack {
614
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
615
                Text(item.element)
616
                    .font(.caption2.weight(.semibold))
617
                    .monospacedDigit()
618
                    .frame(maxWidth: .infinity)
619
            }
620
        }
621
        .foregroundColor(.secondary)
622
    }
623

            
624
    private func rssiYAxisView(context: ChartContext) -> some View {
625
        VStack(spacing: 0) {
626
            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
627
                Spacer(minLength: 0)
628
                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
629
                    .font(.caption2.weight(.semibold))
630
                    .monospacedDigit()
631
                    .foregroundColor(.primary)
632
                Spacer(minLength: 0)
633
            }
634
        }
635
        .padding(.vertical, 12)
636
        .background(
637
            RoundedRectangle(cornerRadius: 16, style: .continuous)
638
                .fill(Color.mint.opacity(0.12))
639
        )
640
        .overlay(
641
            RoundedRectangle(cornerRadius: 16, style: .continuous)
642
                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
643
        )
644
    }
645

            
646
    private func rssiHorizontalGuides(context: ChartContext) -> some View {
647
        GeometryReader { geometry in
648
            Path { path in
649
                for labelIndex in 1...yLabels {
650
                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
651
                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
652
                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
653
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
654
                }
655
            }
656
            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
657
        }
658
    }
659

            
660
    private func rssiVerticalGuides(context: ChartContext) -> some View {
661
        GeometryReader { geometry in
662
            Path { path in
663
                for labelIndex in 2..<xLabels {
664
                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
665
                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
666
                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
667
                    path.move(to: CGPoint(x: x, y: 0))
668
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
669
                }
670
            }
671
            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
672
        }
673
    }
Bogdan Timofte authored 2 weeks ago
674
}