Newer Older
503 lines | 20.279kb
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
                    ControlView()
82
                        .padding(16)
83
                        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
84
                }
85
            }
Bogdan Timofte authored 2 weeks ago
86
            .padding()
Bogdan Timofte authored 2 weeks ago
87
        }
Bogdan Timofte authored 2 weeks ago
88
    }
89

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

            
98
                        landscapeActionPanel(size: size)
99
                    }
100
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
101
                } else {
102
                    connectionCard(compact: true)
103
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
104
                }
Bogdan Timofte authored 2 weeks ago
105
            }
Bogdan Timofte authored 2 weeks ago
106

            
107
            if meter.operationalState == .dataIsAvailable {
108
                landscapeFace {
109
                    LiveView(compactLayout: true, availableSize: size)
110
                        .padding(16)
111
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
112
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
113
                }
114

            
115
                if meter.measurements.power.context.isValid {
116
                    landscapeFace {
117
                        MeasurementChartView()
118
                            .environmentObject(meter.measurements)
119
                            .frame(height: max(250, size.height - 44))
120
                            .padding(10)
121
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
122
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
123
                    }
124
                }
125

            
126
                landscapeFace {
127
                    ControlView(compactLayout: true, availableSize: size)
128
                        .padding(16)
129
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
130
                        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.20)
131
                }
Bogdan Timofte authored 2 weeks ago
132
            }
Bogdan Timofte authored 2 weeks ago
133
        }
134
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
135
    }
136

            
137
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
138
        content()
139
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
140
        .padding(.horizontal, 12)
141
        .padding(.top, 10)
142
        .padding(.bottom, 20)
143
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
144
    }
Bogdan Timofte authored 2 weeks ago
145

            
Bogdan Timofte authored 2 weeks ago
146
    private var meterBackground: some View {
147
        LinearGradient(
148
            colors: [
149
                meter.color.opacity(0.22),
150
                Color.secondary.opacity(0.08),
151
                Color.clear
152
            ],
153
            startPoint: .topLeading,
154
            endPoint: .bottomTrailing
155
        )
156
        .ignoresSafeArea()
157
    }
158

            
159
    private func isLandscape(size: CGSize) -> Bool {
160
        size.width > size.height
161
    }
162

            
163
    private func connectionCard(compact: Bool = false) -> some View {
164
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
165
            HStack(alignment: .top) {
166
                VStack(alignment: .leading, spacing: 6) {
167
                    Text(meter.name)
Bogdan Timofte authored 2 weeks ago
168
                        .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
Bogdan Timofte authored 2 weeks ago
169
                    Text(meter.deviceModelSummary)
170
                        .font(.subheadline.weight(.semibold))
171
                        .foregroundColor(.secondary)
172
                }
173
                Spacer()
Bogdan Timofte authored 2 weeks ago
174
                Group {
175
                    if compact {
176
                        HStack(spacing: 8) {
177
                            statusBadge
178
                            if meter.operationalState > .notPresent {
179
                                Text("RSSI \(meter.btSerial.RSSI)")
180
                                    .font(.caption)
181
                                    .foregroundColor(.secondary)
182
                            }
183
                        }
184
                    } else {
185
                        VStack(alignment: .trailing, spacing: 6) {
186
                            statusBadge
187
                            if meter.operationalState > .notPresent {
188
                                Text("RSSI \(meter.btSerial.RSSI)")
189
                                    .font(.caption)
190
                                    .foregroundColor(.secondary)
191
                            }
192
                        }
Bogdan Timofte authored 2 weeks ago
193
                    }
194
                }
195
            }
Bogdan Timofte authored 2 weeks ago
196

            
Bogdan Timofte authored 2 weeks ago
197
            if compact {
198
                Spacer(minLength: 0)
199
            }
200

            
201
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
202
        }
Bogdan Timofte authored 2 weeks ago
203
        .padding(compact ? 16 : 20)
204
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
205
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
206
    }
207

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

            
211
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
212
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
213
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
214
            let stripContent = HStack(spacing: 0) {
215
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
216
                    dataGroupsViewVisibility.toggle()
217
                }
218
                .sheet(isPresented: $dataGroupsViewVisibility) {
219
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
220
                        .environmentObject(meter)
221
                }
Bogdan Timofte authored 2 weeks ago
222

            
Bogdan Timofte authored 2 weeks ago
223
                if meter.supportsRecordingView {
224
                    actionStripDivider(height: currentActionHeight)
225
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
226
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
227
                    }
Bogdan Timofte authored 2 weeks ago
228
                    .sheet(isPresented: $recordingViewVisibility) {
229
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
230
                            .environmentObject(meter)
231
                    }
Bogdan Timofte authored 2 weeks ago
232
                }
Bogdan Timofte authored 2 weeks ago
233

            
Bogdan Timofte authored 2 weeks ago
234
                actionStripDivider(height: currentActionHeight)
235
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
236
                    measurementsViewVisibility.toggle()
237
                }
238
                .sheet(isPresented: $measurementsViewVisibility) {
239
                    MeasurementsView(visibility: $measurementsViewVisibility)
240
                        .environmentObject(meter.measurements)
241
                }
242
            }
243
            .padding(actionStripPadding)
244
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
245

            
Bogdan Timofte authored 2 weeks ago
246
            HStack {
247
                Spacer(minLength: 0)
248
                if embedded {
249
                    stripContent
250
                } else {
251
                    stripContent
252
                        .meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored 2 weeks ago
253
                }
254
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
255
            }
256
        }
Bogdan Timofte authored 2 weeks ago
257
        .frame(height: currentActionHeight + (actionStripPadding * 2))
258
    }
259

            
260
    private func landscapeActionPanel(size: CGSize) -> some View {
261
        VStack(alignment: .leading, spacing: 14) {
262
            Text("Actions")
263
                .font(.headline)
264

            
265
            Spacer(minLength: 0)
266

            
267
            actionGrid(compact: true, embedded: true)
268

            
269
            Spacer(minLength: 0)
270
        }
271
        .padding(18)
272
        .frame(width: min(420, max(320, size.width * 0.34)))
273
        .frame(maxHeight: .infinity, alignment: .topLeading)
274
        .meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored 2 weeks ago
275
    }
Bogdan Timofte authored 2 weeks ago
276

            
Bogdan Timofte authored 2 weeks ago
277
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
278
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
279
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
280

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

            
Bogdan Timofte authored 2 weeks ago
320
    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
321
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
322
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
323
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
324
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
325
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
326
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
327
                Text(title)
Bogdan Timofte authored 2 weeks ago
328
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
329
                    .multilineTextAlignment(.center)
330
                    .lineLimit(2)
331
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
332
            }
Bogdan Timofte authored 2 weeks ago
333
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
334
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
335
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
336
        }
337
        .buttonStyle(.plain)
338
    }
339

            
Bogdan Timofte authored 2 weeks ago
340
    private var visibleActionButtonCount: CGFloat {
341
        meter.supportsRecordingView ? 3 : 2
342
    }
343

            
344
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
345
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
346
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
347
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
348
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
349
    }
350

            
Bogdan Timofte authored 2 weeks ago
351
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
352
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
353
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
354
    }
355

            
Bogdan Timofte authored 2 weeks ago
356
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
357
        Rectangle()
358
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
359
            .frame(width: actionDividerWidth, height: max(44, height - 22))
360
    }
361

            
362
    private var statusBadge: some View {
363
        Text(statusText)
364
            .font(.caption.weight(.bold))
365
            .padding(.horizontal, 12)
366
            .padding(.vertical, 6)
367
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
368
    }
369

            
370
    private var connectActionTint: Color {
371
        Color(red: 0.20, green: 0.46, blue: 0.43)
372
    }
373

            
374
    private var disconnectActionTint: Color {
375
        Color(red: 0.66, green: 0.39, blue: 0.35)
376
    }
377

            
Bogdan Timofte authored 2 weeks ago
378
    private var statusText: String {
379
        switch meter.operationalState {
380
        case .notPresent:
381
            return "Missing"
382
        case .peripheralNotConnected:
383
            return "Ready"
384
        case .peripheralConnectionPending:
385
            return "Connecting"
386
        case .peripheralConnected:
387
            return "Linked"
388
        case .peripheralReady:
389
            return "Preparing"
390
        case .comunicating:
391
            return "Syncing"
392
        case .dataIsAvailable:
393
            return "Live"
394
        }
395
    }
396

            
397
    private var statusColor: Color {
398
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
399
    }
Bogdan Timofte authored 2 weeks ago
400
}
Bogdan Timofte authored 2 weeks ago
401

            
402
private struct MeterInfoView: View {
403
    @EnvironmentObject private var meter: Meter
404

            
405
    var body: some View {
406
        ScrollView {
407
            VStack(spacing: 14) {
408
                MeterInfoCard(title: "Overview", tint: meter.color) {
409
                    MeterInfoRow(label: "Name", value: meter.name)
410
                    MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
411
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
412
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
413
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
414
                }
415

            
416
                MeterInfoCard(title: "Identifiers", tint: .blue) {
417
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
418
                    if meter.modelNumber != 0 {
419
                        MeterInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
420
                    }
421
                }
422

            
Bogdan Timofte authored 2 weeks ago
423
                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
424
                    if meter.reportsCurrentScreenIndex {
425
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
426
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
427
                            .font(.footnote)
428
                            .foregroundColor(.secondary)
429
                    } else {
430
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
431
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
432
                            .font(.footnote)
433
                            .foregroundColor(.secondary)
434
                    }
435
                }
436

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

            
472
private struct MeterInfoCard<Content: View>: View {
473
    let title: String
474
    let tint: Color
475
    @ViewBuilder var content: Content
476

            
477
    var body: some View {
478
        VStack(alignment: .leading, spacing: 12) {
479
            Text(title)
480
                .font(.headline)
481
            content
482
        }
483
        .frame(maxWidth: .infinity, alignment: .leading)
484
        .padding(18)
485
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
486
    }
487
}
488

            
489
private struct MeterInfoRow: View {
490
    let label: String
491
    let value: String
492

            
493
    var body: some View {
494
        HStack {
495
            Text(label)
496
            Spacer()
497
            Text(value)
498
                .foregroundColor(.secondary)
499
                .multilineTextAlignment(.trailing)
500
        }
501
        .font(.footnote)
502
    }
503
}