Newer Older
465 lines | 18.709kb
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(showsActions: meter.operationalState == .dataIsAvailable)
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
                    LiveView()
68
                        .padding(18)
69
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
70

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

            
84
    private func landscapeDeck(size: CGSize) -> some View {
85
        TabView {
86
            landscapeFace {
Bogdan Timofte authored 2 weeks ago
87
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
Bogdan Timofte authored 2 weeks ago
88
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
89
            }
Bogdan Timofte authored 2 weeks ago
90

            
91
            if meter.operationalState == .dataIsAvailable {
92
                landscapeFace {
93
                    LiveView(compactLayout: true, availableSize: size)
94
                        .padding(16)
Bogdan Timofte authored 2 weeks ago
95
                        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
96
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
97
                }
98

            
99
                if meter.measurements.power.context.isValid {
100
                    landscapeFace {
101
                        MeasurementChartView()
102
                            .environmentObject(meter.measurements)
103
                            .frame(height: max(250, size.height - 44))
104
                            .padding(10)
105
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
106
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
107
                    }
108
                }
Bogdan Timofte authored 2 weeks ago
109
            }
Bogdan Timofte authored 2 weeks ago
110
        }
111
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
112
    }
113

            
114
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
115
        content()
116
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
117
        .padding(.horizontal, 12)
Bogdan Timofte authored 2 weeks ago
118
        .padding(.vertical, 12)
Bogdan Timofte authored 2 weeks ago
119
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
120
    }
Bogdan Timofte authored 2 weeks ago
121

            
Bogdan Timofte authored 2 weeks ago
122
    private var meterBackground: some View {
123
        LinearGradient(
124
            colors: [
125
                meter.color.opacity(0.22),
126
                Color.secondary.opacity(0.08),
127
                Color.clear
128
            ],
129
            startPoint: .topLeading,
130
            endPoint: .bottomTrailing
131
        )
132
        .ignoresSafeArea()
133
    }
134

            
135
    private func isLandscape(size: CGSize) -> Bool {
136
        size.width > size.height
137
    }
138

            
Bogdan Timofte authored 2 weeks ago
139
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
140
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
141
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
142
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
143
                Spacer()
Bogdan Timofte authored 2 weeks ago
144
                statusBadge
Bogdan Timofte authored 2 weeks ago
145
            }
Bogdan Timofte authored 2 weeks ago
146

            
Bogdan Timofte authored 2 weeks ago
147
            if compact {
148
                Spacer(minLength: 0)
149
            }
150

            
151
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
152

            
153
            if showsActions {
154
                VStack(spacing: compact ? 10 : 12) {
155
                    Rectangle()
156
                        .fill(Color.secondary.opacity(0.12))
157
                        .frame(height: 1)
158

            
159
                    actionGrid(compact: compact, embedded: true)
160
                }
161
            }
Bogdan Timofte authored 2 weeks ago
162
        }
Bogdan Timofte authored 2 weeks ago
163
        .padding(compact ? 16 : 20)
164
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
165
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
166
    }
167

            
Bogdan Timofte authored 2 weeks ago
168
    private func meterIdentity(compact: Bool) -> some View {
169
        HStack(alignment: .firstTextBaseline, spacing: 8) {
170
            Text(meter.name)
171
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
172
                .lineLimit(1)
173
                .minimumScaleFactor(0.8)
174

            
175
            Text(meter.deviceModelName)
176
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
177
                .foregroundColor(.secondary)
178
                .lineLimit(1)
179
                .minimumScaleFactor(0.8)
180
        }
181
    }
182

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

            
186
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
187
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
188
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
189
            let stripContent = HStack(spacing: 0) {
190
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
191
                    dataGroupsViewVisibility.toggle()
192
                }
193
                .sheet(isPresented: $dataGroupsViewVisibility) {
194
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
195
                        .environmentObject(meter)
196
                }
Bogdan Timofte authored 2 weeks ago
197

            
Bogdan Timofte authored 2 weeks ago
198
                if meter.supportsRecordingView {
199
                    actionStripDivider(height: currentActionHeight)
200
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
201
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
202
                    }
Bogdan Timofte authored 2 weeks ago
203
                    .sheet(isPresented: $recordingViewVisibility) {
204
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
205
                            .environmentObject(meter)
206
                    }
Bogdan Timofte authored 2 weeks ago
207
                }
Bogdan Timofte authored 2 weeks ago
208

            
Bogdan Timofte authored 2 weeks ago
209
                actionStripDivider(height: currentActionHeight)
210
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
211
                    measurementsViewVisibility.toggle()
212
                }
213
                .sheet(isPresented: $measurementsViewVisibility) {
214
                    MeasurementsView(visibility: $measurementsViewVisibility)
215
                        .environmentObject(meter.measurements)
216
                }
217
            }
218
            .padding(actionStripPadding)
219
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
220

            
Bogdan Timofte authored 2 weeks ago
221
            HStack {
222
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
223
                stripContent
224
                    .meterCard(
225
                        tint: embedded ? meter.color : Color.secondary,
226
                        fillOpacity: embedded ? 0.08 : 0.10,
227
                        strokeOpacity: embedded ? 0.14 : 0.16,
228
                        cornerRadius: embedded ? 24 : 22
229
                    )
Bogdan Timofte authored 2 weeks ago
230
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
231
            }
232
        }
Bogdan Timofte authored 2 weeks ago
233
        .frame(height: currentActionHeight + (actionStripPadding * 2))
234
    }
235

            
236
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
237
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
238
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
239

            
Bogdan Timofte authored 2 weeks ago
240
        return Group {
241
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
242
                HStack(spacing: 10) {
243
                    Image(systemName: "exclamationmark.triangle.fill")
244
                        .foregroundColor(.orange)
245
                    Text("Not found at this time.")
246
                        .fontWeight(.semibold)
247
                    Spacer()
248
                }
Bogdan Timofte authored 2 weeks ago
249
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
250
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
251
            } else {
Bogdan Timofte authored 2 weeks ago
252
                Button(action: {
Bogdan Timofte authored 2 weeks ago
253
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
254
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
255
                    } else {
Bogdan Timofte authored 2 weeks ago
256
                        meter.disconnect()
257
                    }
258
                }) {
Bogdan Timofte authored 2 weeks ago
259
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
260
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
261
                            .foregroundColor(tint)
262
                            .frame(width: 30, height: 30)
263
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
264
                        Text(connected ? "Disconnect" : "Connect")
265
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
266
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
267
                        Spacer()
Bogdan Timofte authored 2 weeks ago
268
                    }
Bogdan Timofte authored 2 weeks ago
269
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
270
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
271
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
272
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
273
                }
Bogdan Timofte authored 2 weeks ago
274
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
275
            }
276
        }
277
    }
Bogdan Timofte authored 2 weeks ago
278

            
Bogdan Timofte authored 2 weeks ago
279
    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
280
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
281
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
282
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
283
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
284
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
285
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
286
                Text(title)
Bogdan Timofte authored 2 weeks ago
287
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
288
                    .multilineTextAlignment(.center)
289
                    .lineLimit(2)
290
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
291
            }
Bogdan Timofte authored 2 weeks ago
292
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
293
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
294
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
295
        }
296
        .buttonStyle(.plain)
297
    }
298

            
Bogdan Timofte authored 2 weeks ago
299
    private var visibleActionButtonCount: CGFloat {
300
        meter.supportsRecordingView ? 3 : 2
301
    }
302

            
303
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
304
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
305
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
306
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
307
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
308
    }
309

            
Bogdan Timofte authored 2 weeks ago
310
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
311
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
312
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
313
    }
314

            
Bogdan Timofte authored 2 weeks ago
315
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
316
        Rectangle()
317
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
318
            .frame(width: actionDividerWidth, height: max(44, height - 22))
319
    }
320

            
321
    private var statusBadge: some View {
322
        Text(statusText)
323
            .font(.caption.weight(.bold))
324
            .padding(.horizontal, 12)
325
            .padding(.vertical, 6)
326
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
327
    }
328

            
329
    private var connectActionTint: Color {
330
        Color(red: 0.20, green: 0.46, blue: 0.43)
331
    }
332

            
333
    private var disconnectActionTint: Color {
334
        Color(red: 0.66, green: 0.39, blue: 0.35)
335
    }
336

            
Bogdan Timofte authored 2 weeks ago
337
    private var statusText: String {
338
        switch meter.operationalState {
339
        case .notPresent:
340
            return "Missing"
341
        case .peripheralNotConnected:
342
            return "Ready"
343
        case .peripheralConnectionPending:
344
            return "Connecting"
345
        case .peripheralConnected:
346
            return "Linked"
347
        case .peripheralReady:
348
            return "Preparing"
349
        case .comunicating:
350
            return "Syncing"
351
        case .dataIsAvailable:
352
            return "Live"
353
        }
354
    }
355

            
356
    private var statusColor: Color {
357
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
358
    }
Bogdan Timofte authored 2 weeks ago
359
}
Bogdan Timofte authored 2 weeks ago
360

            
361
private struct MeterInfoView: View {
362
    @EnvironmentObject private var meter: Meter
363

            
364
    var body: some View {
365
        ScrollView {
366
            VStack(spacing: 14) {
367
                MeterInfoCard(title: "Overview", tint: meter.color) {
368
                    MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
369
                    MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
Bogdan Timofte authored 2 weeks ago
370
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
371
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
372
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
373
                }
374

            
375
                MeterInfoCard(title: "Identifiers", tint: .blue) {
376
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
377
                    if meter.modelNumber != 0 {
Bogdan Timofte authored 2 weeks ago
378
                        MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
Bogdan Timofte authored 2 weeks ago
379
                    }
380
                }
381

            
Bogdan Timofte authored 2 weeks ago
382
                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
383
                    if meter.reportsCurrentScreenIndex {
384
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
385
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
386
                            .font(.footnote)
387
                            .foregroundColor(.secondary)
388
                    } else {
389
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
390
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
391
                            .font(.footnote)
392
                            .foregroundColor(.secondary)
393
                    }
394
                }
395

            
Bogdan Timofte authored 2 weeks ago
396
                if meter.operationalState == .dataIsAvailable {
397
                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
398
                        if !meter.firmwareVersion.isEmpty {
399
                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
400
                        }
Bogdan Timofte authored 2 weeks ago
401
                        if meter.supportsChargerDetection {
402
                            MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
403
                        }
Bogdan Timofte authored 2 weeks ago
404
                        if meter.serialNumber != 0 {
405
                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
406
                        }
407
                        if meter.bootCount != 0 {
408
                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
409
                        }
410
                    }
411
                } else {
412
                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
413
                        Text("Connect to the meter to load firmware, serial, and boot details.")
414
                            .font(.footnote)
415
                            .foregroundColor(.secondary)
416
                    }
417
                }
418
            }
419
            .padding()
420
        }
421
        .background(
422
            LinearGradient(
423
                colors: [meter.color.opacity(0.14), Color.clear],
424
                startPoint: .topLeading,
425
                endPoint: .bottomTrailing
426
            )
427
            .ignoresSafeArea()
428
        )
Bogdan Timofte authored 2 weeks ago
429
        .navigationBarTitle("Device Info")
Bogdan Timofte authored 2 weeks ago
430
        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
Bogdan Timofte authored 2 weeks ago
431
    }
432
}
433

            
434
private struct MeterInfoCard<Content: View>: View {
435
    let title: String
436
    let tint: Color
437
    @ViewBuilder var content: Content
438

            
439
    var body: some View {
440
        VStack(alignment: .leading, spacing: 12) {
441
            Text(title)
442
                .font(.headline)
443
            content
444
        }
445
        .frame(maxWidth: .infinity, alignment: .leading)
446
        .padding(18)
447
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
448
    }
449
}
450

            
451
private struct MeterInfoRow: View {
452
    let label: String
453
    let value: String
454

            
455
    var body: some View {
456
        HStack {
457
            Text(label)
458
            Spacer()
459
            Text(value)
460
                .foregroundColor(.secondary)
461
                .multilineTextAlignment(.trailing)
462
        }
463
        .font(.footnote)
464
    }
465
}