Newer Older
491 lines | 19.72kb
Bogdan Timofte authored 2 weeks ago
1
//
2
//  MeterView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 04/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
// MARK: Parent frame https://stackoverflow.com/questions/56832865/how-to-access-parents-frame-in-swiftui
9

            
10
import SwiftUI
11
import CoreBluetooth
12

            
13
struct MeterView: View {
14

            
15
    @EnvironmentObject private var meter: Meter
16

            
17
    @State var dataGroupsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
18
    @State var recordingViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
19
    @State var measurementsViewVisibility: Bool = false
20
    private var myBounds: CGRect { UIScreen.main.bounds }
Bogdan Timofte authored 2 weeks ago
21
    private let actionStripPadding: CGFloat = 10
22
    private let actionDividerWidth: CGFloat = 1
Bogdan Timofte authored 2 weeks ago
23
    private let actionButtonMaxWidth: CGFloat = 156
24
    private let actionButtonMinWidth: CGFloat = 88
25
    private let actionButtonHeight: CGFloat = 108
Bogdan Timofte authored 2 weeks ago
26

            
Bogdan Timofte authored 2 weeks ago
27
    var body: some View {
Bogdan Timofte authored 2 weeks ago
28
        GeometryReader { proxy in
29
            let landscape = isLandscape(size: proxy.size)
30

            
31
            Group {
32
                if landscape {
33
                    landscapeDeck(size: proxy.size)
34
                } else {
35
                    portraitContent
36
                }
37
            }
38
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
39
            .navigationBarHidden(landscape)
40
        }
41
        .background(meterBackground)
42
        .navigationBarTitle("Meter")
43
        .navigationBarItems(trailing: HStack (spacing: 6) {
44
            if meter.operationalState > .notPresent {
45
                RSSIView(RSSI: meter.btSerial.RSSI)
46
                    .frame(width: 18, height: 18)
47
                    .padding(.leading, 6)
48
                    .padding(.vertical)
49
            }
50
            NavigationLink(destination: MeterInfoView().environmentObject(meter)) {
51
                Image(systemName: "info.circle.fill")
52
                    .padding(.vertical)
53
            }
54
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
55
                Image(systemName: "gearshape.fill")
56
                    .padding(.vertical)
57
            }
58
        })
59
    }
60

            
61
    private var portraitContent: some View {
Bogdan Timofte authored 2 weeks ago
62
        ScrollView {
Bogdan Timofte authored 2 weeks ago
63
            VStack(alignment: .leading, spacing: 16) {
Bogdan Timofte authored 2 weeks ago
64
                connectionCard()
Bogdan Timofte authored 2 weeks ago
65

            
Bogdan Timofte authored 2 weeks ago
66
                if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored 2 weeks ago
67
                    actionGrid()
Bogdan Timofte authored 2 weeks ago
68

            
Bogdan Timofte authored 2 weeks ago
69
                    LiveView()
70
                        .padding(18)
71
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
72

            
Bogdan Timofte authored 2 weeks ago
73
                    if meter.measurements.power.context.isValid {
74
                        MeasurementChartView()
75
                            .environmentObject(meter.measurements)
76
                            .frame(minHeight: myBounds.height / 3.4)
77
                            .padding(16)
78
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
79
                    }
Bogdan Timofte authored 2 weeks ago
80
                }
81
            }
Bogdan Timofte authored 2 weeks ago
82
            .padding()
Bogdan Timofte authored 2 weeks ago
83
        }
Bogdan Timofte authored 2 weeks ago
84
    }
85

            
86
    private func landscapeDeck(size: CGSize) -> some View {
87
        TabView {
88
            landscapeFace {
89
                if meter.operationalState == .dataIsAvailable {
90
                    HStack(alignment: .top, spacing: 16) {
91
                        connectionCard(compact: true)
92
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
93

            
94
                        landscapeActionPanel(size: size)
95
                    }
96
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
97
                } else {
98
                    connectionCard(compact: true)
99
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
100
                }
Bogdan Timofte authored 2 weeks ago
101
            }
Bogdan Timofte authored 2 weeks ago
102

            
103
            if meter.operationalState == .dataIsAvailable {
104
                landscapeFace {
105
                    LiveView(compactLayout: true, availableSize: size)
106
                        .padding(16)
Bogdan Timofte authored 2 weeks ago
107
                        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
108
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
109
                }
110

            
111
                if meter.measurements.power.context.isValid {
112
                    landscapeFace {
113
                        MeasurementChartView()
114
                            .environmentObject(meter.measurements)
115
                            .frame(height: max(250, size.height - 44))
116
                            .padding(10)
117
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
118
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
119
                    }
120
                }
Bogdan Timofte authored 2 weeks ago
121
            }
Bogdan Timofte authored 2 weeks ago
122
        }
123
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
124
    }
125

            
126
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
127
        content()
128
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
129
        .padding(.horizontal, 12)
Bogdan Timofte authored 2 weeks ago
130
        .padding(.vertical, 12)
Bogdan Timofte authored 2 weeks ago
131
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
132
    }
Bogdan Timofte authored 2 weeks ago
133

            
Bogdan Timofte authored 2 weeks ago
134
    private var meterBackground: some View {
135
        LinearGradient(
136
            colors: [
137
                meter.color.opacity(0.22),
138
                Color.secondary.opacity(0.08),
139
                Color.clear
140
            ],
141
            startPoint: .topLeading,
142
            endPoint: .bottomTrailing
143
        )
144
        .ignoresSafeArea()
145
    }
146

            
147
    private func isLandscape(size: CGSize) -> Bool {
148
        size.width > size.height
149
    }
150

            
151
    private func connectionCard(compact: Bool = false) -> some View {
152
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
153
            HStack(alignment: .top) {
154
                VStack(alignment: .leading, spacing: 6) {
155
                    Text(meter.name)
Bogdan Timofte authored 2 weeks ago
156
                        .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
Bogdan Timofte authored 2 weeks ago
157
                    Text(meter.deviceModelSummary)
158
                        .font(.subheadline.weight(.semibold))
159
                        .foregroundColor(.secondary)
160
                }
161
                Spacer()
Bogdan Timofte authored 2 weeks ago
162
                Group {
163
                    if compact {
164
                        HStack(spacing: 8) {
165
                            statusBadge
166
                            if meter.operationalState > .notPresent {
167
                                Text("RSSI \(meter.btSerial.RSSI)")
168
                                    .font(.caption)
169
                                    .foregroundColor(.secondary)
170
                            }
171
                        }
172
                    } else {
173
                        VStack(alignment: .trailing, spacing: 6) {
174
                            statusBadge
175
                            if meter.operationalState > .notPresent {
176
                                Text("RSSI \(meter.btSerial.RSSI)")
177
                                    .font(.caption)
178
                                    .foregroundColor(.secondary)
179
                            }
180
                        }
Bogdan Timofte authored 2 weeks ago
181
                    }
182
                }
183
            }
Bogdan Timofte authored 2 weeks ago
184

            
Bogdan Timofte authored 2 weeks ago
185
            if compact {
186
                Spacer(minLength: 0)
187
            }
188

            
189
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
190
        }
Bogdan Timofte authored 2 weeks ago
191
        .padding(compact ? 16 : 20)
192
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
193
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
194
    }
195

            
Bogdan Timofte authored 2 weeks ago
196
    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
197
        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
198

            
199
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
200
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
201
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
202
            let stripContent = HStack(spacing: 0) {
203
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
204
                    dataGroupsViewVisibility.toggle()
205
                }
206
                .sheet(isPresented: $dataGroupsViewVisibility) {
207
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
208
                        .environmentObject(meter)
209
                }
Bogdan Timofte authored 2 weeks ago
210

            
Bogdan Timofte authored 2 weeks ago
211
                if meter.supportsRecordingView {
212
                    actionStripDivider(height: currentActionHeight)
213
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
214
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
215
                    }
Bogdan Timofte authored 2 weeks ago
216
                    .sheet(isPresented: $recordingViewVisibility) {
217
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
218
                            .environmentObject(meter)
219
                    }
Bogdan Timofte authored 2 weeks ago
220
                }
Bogdan Timofte authored 2 weeks ago
221

            
Bogdan Timofte authored 2 weeks ago
222
                actionStripDivider(height: currentActionHeight)
223
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
224
                    measurementsViewVisibility.toggle()
225
                }
226
                .sheet(isPresented: $measurementsViewVisibility) {
227
                    MeasurementsView(visibility: $measurementsViewVisibility)
228
                        .environmentObject(meter.measurements)
229
                }
230
            }
231
            .padding(actionStripPadding)
232
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
233

            
Bogdan Timofte authored 2 weeks ago
234
            HStack {
235
                Spacer(minLength: 0)
236
                if embedded {
237
                    stripContent
238
                } else {
239
                    stripContent
240
                        .meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored 2 weeks ago
241
                }
242
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
243
            }
244
        }
Bogdan Timofte authored 2 weeks ago
245
        .frame(height: currentActionHeight + (actionStripPadding * 2))
246
    }
247

            
248
    private func landscapeActionPanel(size: CGSize) -> some View {
249
        VStack(alignment: .leading, spacing: 14) {
250
            Text("Actions")
251
                .font(.headline)
252

            
253
            Spacer(minLength: 0)
254

            
255
            actionGrid(compact: true, embedded: true)
256

            
257
            Spacer(minLength: 0)
258
        }
259
        .padding(18)
260
        .frame(width: min(420, max(320, size.width * 0.34)))
261
        .frame(maxHeight: .infinity, alignment: .topLeading)
262
        .meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored 2 weeks ago
263
    }
Bogdan Timofte authored 2 weeks ago
264

            
Bogdan Timofte authored 2 weeks ago
265
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
266
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
267
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
268

            
Bogdan Timofte authored 2 weeks ago
269
        return Group {
270
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
271
                HStack(spacing: 10) {
272
                    Image(systemName: "exclamationmark.triangle.fill")
273
                        .foregroundColor(.orange)
274
                    Text("Not found at this time.")
275
                        .fontWeight(.semibold)
276
                    Spacer()
277
                }
Bogdan Timofte authored 2 weeks ago
278
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
279
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
280
            } else {
Bogdan Timofte authored 2 weeks ago
281
                Button(action: {
Bogdan Timofte authored 2 weeks ago
282
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
283
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
284
                    } else {
Bogdan Timofte authored 2 weeks ago
285
                        meter.disconnect()
286
                    }
287
                }) {
Bogdan Timofte authored 2 weeks ago
288
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
289
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
290
                            .foregroundColor(tint)
291
                            .frame(width: 30, height: 30)
292
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
293
                        Text(connected ? "Disconnect" : "Connect")
294
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
295
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
296
                        Spacer()
Bogdan Timofte authored 2 weeks ago
297
                    }
Bogdan Timofte authored 2 weeks ago
298
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
299
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
300
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
301
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
302
                }
Bogdan Timofte authored 2 weeks ago
303
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
304
            }
305
        }
306
    }
Bogdan Timofte authored 2 weeks ago
307

            
Bogdan Timofte authored 2 weeks ago
308
    fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
Bogdan Timofte authored 2 weeks ago
309
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
310
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
311
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
312
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
313
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
314
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
315
                Text(title)
Bogdan Timofte authored 2 weeks ago
316
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
317
                    .multilineTextAlignment(.center)
318
                    .lineLimit(2)
319
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
320
            }
Bogdan Timofte authored 2 weeks ago
321
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
322
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
323
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
324
        }
325
        .buttonStyle(.plain)
326
    }
327

            
Bogdan Timofte authored 2 weeks ago
328
    private var visibleActionButtonCount: CGFloat {
329
        meter.supportsRecordingView ? 3 : 2
330
    }
331

            
332
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
333
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
334
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
335
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
336
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
337
    }
338

            
Bogdan Timofte authored 2 weeks ago
339
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
340
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
341
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
342
    }
343

            
Bogdan Timofte authored 2 weeks ago
344
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
345
        Rectangle()
346
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
347
            .frame(width: actionDividerWidth, height: max(44, height - 22))
348
    }
349

            
350
    private var statusBadge: some View {
351
        Text(statusText)
352
            .font(.caption.weight(.bold))
353
            .padding(.horizontal, 12)
354
            .padding(.vertical, 6)
355
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
356
    }
357

            
358
    private var connectActionTint: Color {
359
        Color(red: 0.20, green: 0.46, blue: 0.43)
360
    }
361

            
362
    private var disconnectActionTint: Color {
363
        Color(red: 0.66, green: 0.39, blue: 0.35)
364
    }
365

            
Bogdan Timofte authored 2 weeks ago
366
    private var statusText: String {
367
        switch meter.operationalState {
368
        case .notPresent:
369
            return "Missing"
370
        case .peripheralNotConnected:
371
            return "Ready"
372
        case .peripheralConnectionPending:
373
            return "Connecting"
374
        case .peripheralConnected:
375
            return "Linked"
376
        case .peripheralReady:
377
            return "Preparing"
378
        case .comunicating:
379
            return "Syncing"
380
        case .dataIsAvailable:
381
            return "Live"
382
        }
383
    }
384

            
385
    private var statusColor: Color {
386
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
387
    }
Bogdan Timofte authored 2 weeks ago
388
}
Bogdan Timofte authored 2 weeks ago
389

            
390
private struct MeterInfoView: View {
391
    @EnvironmentObject private var meter: Meter
392

            
393
    var body: some View {
394
        ScrollView {
395
            VStack(spacing: 14) {
396
                MeterInfoCard(title: "Overview", tint: meter.color) {
397
                    MeterInfoRow(label: "Name", value: meter.name)
398
                    MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
399
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
400
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
401
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
402
                }
403

            
404
                MeterInfoCard(title: "Identifiers", tint: .blue) {
405
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
406
                    if meter.modelNumber != 0 {
407
                        MeterInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
408
                    }
409
                }
410

            
Bogdan Timofte authored 2 weeks ago
411
                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
412
                    if meter.reportsCurrentScreenIndex {
413
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
414
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
415
                            .font(.footnote)
416
                            .foregroundColor(.secondary)
417
                    } else {
418
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
419
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
420
                            .font(.footnote)
421
                            .foregroundColor(.secondary)
422
                    }
423
                }
424

            
Bogdan Timofte authored 2 weeks ago
425
                if meter.operationalState == .dataIsAvailable {
426
                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
427
                        if !meter.firmwareVersion.isEmpty {
428
                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
429
                        }
430
                        if meter.serialNumber != 0 {
431
                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
432
                        }
433
                        if meter.bootCount != 0 {
434
                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
435
                        }
436
                    }
437
                } else {
438
                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
439
                        Text("Connect to the meter to load firmware, serial, and boot details.")
440
                            .font(.footnote)
441
                            .foregroundColor(.secondary)
442
                    }
443
                }
444
            }
445
            .padding()
446
        }
447
        .background(
448
            LinearGradient(
449
                colors: [meter.color.opacity(0.14), Color.clear],
450
                startPoint: .topLeading,
451
                endPoint: .bottomTrailing
452
            )
453
            .ignoresSafeArea()
454
        )
455
        .navigationBarTitle("Meter Info")
Bogdan Timofte authored 2 weeks ago
456
        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
Bogdan Timofte authored 2 weeks ago
457
    }
458
}
459

            
460
private struct MeterInfoCard<Content: View>: View {
461
    let title: String
462
    let tint: Color
463
    @ViewBuilder var content: Content
464

            
465
    var body: some View {
466
        VStack(alignment: .leading, spacing: 12) {
467
            Text(title)
468
                .font(.headline)
469
            content
470
        }
471
        .frame(maxWidth: .infinity, alignment: .leading)
472
        .padding(18)
473
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
474
    }
475
}
476

            
477
private struct MeterInfoRow: View {
478
    let label: String
479
    let value: String
480

            
481
    var body: some View {
482
        HStack {
483
            Text(label)
484
            Spacer()
485
            Text(value)
486
                .foregroundColor(.secondary)
487
                .multilineTextAlignment(.trailing)
488
        }
489
        .font(.footnote)
490
    }
491
}