@@ -493,8 +493,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 493 | 493 |
private var chargeRecordLastTimestamp: Date? |
| 494 | 494 |
private var chargeRecordLastCurrent: Double = 0 |
| 495 | 495 |
private var chargeRecordLastPower: Double = 0 |
| 496 |
+ private let volatileMemoryDecreaseEpsilon = 0.0005 |
|
| 497 |
+ private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12 |
|
| 498 |
+ private var hasSeenUMSnapshot = false |
|
| 496 | 499 |
private var hasObservedActiveDataGroup = false |
| 497 | 500 |
private var hasSeenTC66Snapshot = false |
| 501 |
+ private var pendingVolatileMemoryResetIgnoreCount = 0 |
|
| 502 |
+ private var pendingVolatileMemoryResetDeadline: Date? |
|
| 498 | 503 |
|
| 499 | 504 |
init ( model: Model, with serialPort: BluetoothSerial ) {
|
| 500 | 505 |
uuid = serialPort.peripheral.identifier |
@@ -540,6 +545,82 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 540 | 545 |
track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
|
| 541 | 546 |
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) |
| 542 | 547 |
} |
| 548 |
+ |
|
| 549 |
+ private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
|
|
| 550 |
+ guard groupID == 0 else { return }
|
|
| 551 |
+ pendingVolatileMemoryResetIgnoreCount += 1 |
|
| 552 |
+ pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow) |
|
| 553 |
+ track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
|
|
| 554 |
+ } |
|
| 555 |
+ |
|
| 556 |
+ private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
|
|
| 557 |
+ guard let pendingVolatileMemoryResetDeadline else { return false }
|
|
| 558 |
+ guard pendingVolatileMemoryResetIgnoreCount > 0 else {
|
|
| 559 |
+ self.pendingVolatileMemoryResetDeadline = nil |
|
| 560 |
+ return false |
|
| 561 |
+ } |
|
| 562 |
+ guard timestamp <= pendingVolatileMemoryResetDeadline else {
|
|
| 563 |
+ track("\(name) - Expiring stale volatile memory reset ignore state.")
|
|
| 564 |
+ pendingVolatileMemoryResetIgnoreCount = 0 |
|
| 565 |
+ self.pendingVolatileMemoryResetDeadline = nil |
|
| 566 |
+ return false |
|
| 567 |
+ } |
|
| 568 |
+ return true |
|
| 569 |
+ } |
|
| 570 |
+ |
|
| 571 |
+ private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
|
|
| 572 |
+ guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
|
|
| 573 |
+ pendingVolatileMemoryResetIgnoreCount -= 1 |
|
| 574 |
+ if pendingVolatileMemoryResetIgnoreCount == 0 {
|
|
| 575 |
+ pendingVolatileMemoryResetDeadline = nil |
|
| 576 |
+ } |
|
| 577 |
+ track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
|
|
| 578 |
+ return true |
|
| 579 |
+ } |
|
| 580 |
+ |
|
| 581 |
+ private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
|
|
| 582 |
+ guard hasSeenUMSnapshot else { return false }
|
|
| 583 |
+ guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
|
|
| 584 |
+ return false |
|
| 585 |
+ } |
|
| 586 |
+ |
|
| 587 |
+ return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon) |
|
| 588 |
+ || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon) |
|
| 589 |
+ } |
|
| 590 |
+ |
|
| 591 |
+ private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
|
|
| 592 |
+ defer { hasSeenUMSnapshot = true }
|
|
| 593 |
+ |
|
| 594 |
+ guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
|
|
| 595 |
+ guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
|
|
| 596 |
+ |
|
| 597 |
+ track("\(name) - Inferred UM reboot because volatile memory dropped.")
|
|
| 598 |
+ return true |
|
| 599 |
+ } |
|
| 600 |
+ |
|
| 601 |
+ private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
|
|
| 602 |
+ guard hasSeenTC66Snapshot else { return false }
|
|
| 603 |
+ guard snapshot.bootCount != bootCount else { return false }
|
|
| 604 |
+ |
|
| 605 |
+ track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
|
|
| 606 |
+ return true |
|
| 607 |
+ } |
|
| 608 |
+ |
|
| 609 |
+ private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
|
|
| 610 |
+ if didDetectDeviceReset, chargerTypeIndex != 0 {
|
|
| 611 |
+ chargerTypeIndex = 0 |
|
| 612 |
+ } |
|
| 613 |
+ |
|
| 614 |
+ guard supportsChargerDetection else { return }
|
|
| 615 |
+ |
|
| 616 |
+ if chargerTypeIndex == 0 {
|
|
| 617 |
+ chargerTypeIndex = observedIndex |
|
| 618 |
+ return |
|
| 619 |
+ } |
|
| 620 |
+ |
|
| 621 |
+ guard observedIndex != 0, observedIndex != chargerTypeIndex else { return }
|
|
| 622 |
+ track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.")
|
|
| 623 |
+ } |
|
| 543 | 624 |
|
| 544 | 625 |
func dataDumpRequest() {
|
| 545 | 626 |
guard operationalState >= .peripheralReady else {
|
@@ -602,6 +683,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 602 | 683 |
} |
| 603 | 684 |
|
| 604 | 685 |
private func apply(umSnapshot snapshot: UMSnapshot) {
|
| 686 |
+ let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp) |
|
| 605 | 687 |
modelNumber = snapshot.modelNumber |
| 606 | 688 |
voltage = snapshot.voltage |
| 607 | 689 |
current = snapshot.current |
@@ -614,7 +696,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 614 | 696 |
} |
| 615 | 697 |
usbPlusVoltage = snapshot.usbPlusVoltage |
| 616 | 698 |
usbMinusVoltage = snapshot.usbMinusVoltage |
| 617 |
- chargerTypeIndex = snapshot.chargerTypeIndex |
|
| 699 |
+ updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset) |
|
| 618 | 700 |
recordedAH = snapshot.recordedAH |
| 619 | 701 |
recordedWH = snapshot.recordedWH |
| 620 | 702 |
|
@@ -652,6 +734,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 652 | 734 |
} |
| 653 | 735 |
|
| 654 | 736 |
private func apply(tc66Snapshot snapshot: TC66Snapshot) {
|
| 737 |
+ let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot) |
|
| 655 | 738 |
if hasSeenTC66Snapshot {
|
| 656 | 739 |
inferTC66ActiveDataGroup(from: snapshot) |
| 657 | 740 |
} else {
|
@@ -661,6 +744,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 661 | 744 |
firmwareVersion = snapshot.firmwareVersion |
| 662 | 745 |
serialNumber = snapshot.serialNumber |
| 663 | 746 |
bootCount = snapshot.bootCount |
| 747 |
+ updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset) |
|
| 664 | 748 |
voltage = snapshot.voltage |
| 665 | 749 |
current = snapshot.current |
| 666 | 750 |
power = snapshot.power |
@@ -774,13 +858,15 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 774 | 858 |
|
| 775 | 859 |
func clear() {
|
| 776 | 860 |
guard supportsDataGroupCommands else { return }
|
| 861 |
+ noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup) |
|
| 777 | 862 |
commandQueue.append(UMProtocol.clearCurrentGroup) |
| 778 | 863 |
} |
| 779 | 864 |
|
| 780 | 865 |
func clear(group id: UInt8) {
|
| 781 | 866 |
guard supportsDataGroupCommands else { return }
|
| 782 | 867 |
commandQueue.append(UMProtocol.selectDataGroup(id)) |
| 783 |
- clear() |
|
| 868 |
+ noteInitiatedVolatileMemoryResetIfNeeded(for: id) |
|
| 869 |
+ commandQueue.append(UMProtocol.clearCurrentGroup) |
|
| 784 | 870 |
commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup)) |
| 785 | 871 |
} |
| 786 | 872 |
|
@@ -98,6 +98,12 @@ extension MeterCapabilities {
|
||
| 98 | 98 |
} |
| 99 | 99 |
|
| 100 | 100 |
extension Model {
|
| 101 |
+ private static let tc66Tint = Color( |
|
| 102 |
+ uiColor: UIColor { traits in
|
|
| 103 |
+ traits.userInterfaceStyle == .dark ? .systemGray2 : .black |
|
| 104 |
+ } |
|
| 105 |
+ ) |
|
| 106 |
+ |
|
| 101 | 107 |
static let byPeripheralName = Dictionary( |
| 102 | 108 |
uniqueKeysWithValues: allCases.flatMap { model in
|
| 103 | 109 |
model.peripheralNames.map { ($0, model) }
|
@@ -131,7 +137,7 @@ extension Model {
|
||
| 131 | 137 |
case .UM34C: |
| 132 | 138 |
return .yellow |
| 133 | 139 |
case .TC66C: |
| 134 |
- return .black |
|
| 140 |
+ return Self.tc66Tint |
|
| 135 | 141 |
} |
| 136 | 142 |
} |
| 137 | 143 |
|
@@ -15,6 +15,60 @@ struct LiveView: View {
|
||
| 15 | 15 |
let minValue: String |
| 16 | 16 |
let maxValue: String |
| 17 | 17 |
} |
| 18 |
+ |
|
| 19 |
+ private struct LoadResistanceSymbol: View {
|
|
| 20 |
+ let color: Color |
|
| 21 |
+ |
|
| 22 |
+ var body: some View {
|
|
| 23 |
+ GeometryReader { proxy in
|
|
| 24 |
+ let width = proxy.size.width |
|
| 25 |
+ let height = proxy.size.height |
|
| 26 |
+ let midY = height / 2 |
|
| 27 |
+ let startX = width * 0.10 |
|
| 28 |
+ let endX = width * 0.90 |
|
| 29 |
+ let boxMinX = width * 0.28 |
|
| 30 |
+ let boxMaxX = width * 0.72 |
|
| 31 |
+ let boxHeight = height * 0.34 |
|
| 32 |
+ let boxRect = CGRect( |
|
| 33 |
+ x: boxMinX, |
|
| 34 |
+ y: midY - (boxHeight / 2), |
|
| 35 |
+ width: boxMaxX - boxMinX, |
|
| 36 |
+ height: boxHeight |
|
| 37 |
+ ) |
|
| 38 |
+ let strokeWidth = max(1.2, height * 0.055) |
|
| 39 |
+ |
|
| 40 |
+ ZStack {
|
|
| 41 |
+ Path { path in
|
|
| 42 |
+ path.move(to: CGPoint(x: startX, y: midY)) |
|
| 43 |
+ path.addLine(to: CGPoint(x: boxRect.minX, y: midY)) |
|
| 44 |
+ path.move(to: CGPoint(x: boxRect.maxX, y: midY)) |
|
| 45 |
+ path.addLine(to: CGPoint(x: endX, y: midY)) |
|
| 46 |
+ } |
|
| 47 |
+ .stroke( |
|
| 48 |
+ color, |
|
| 49 |
+ style: StrokeStyle( |
|
| 50 |
+ lineWidth: strokeWidth, |
|
| 51 |
+ lineCap: .round, |
|
| 52 |
+ lineJoin: .round |
|
| 53 |
+ ) |
|
| 54 |
+ ) |
|
| 55 |
+ |
|
| 56 |
+ Path { path in
|
|
| 57 |
+ path.addRect(boxRect) |
|
| 58 |
+ } |
|
| 59 |
+ .stroke( |
|
| 60 |
+ color, |
|
| 61 |
+ style: StrokeStyle( |
|
| 62 |
+ lineWidth: strokeWidth, |
|
| 63 |
+ lineCap: .round, |
|
| 64 |
+ lineJoin: .round |
|
| 65 |
+ ) |
|
| 66 |
+ ) |
|
| 67 |
+ } |
|
| 68 |
+ } |
|
| 69 |
+ .padding(4) |
|
| 70 |
+ } |
|
| 71 |
+ } |
|
| 18 | 72 |
|
| 19 | 73 |
@EnvironmentObject private var meter: Meter |
| 20 | 74 |
var compactLayout: Bool = false |
@@ -73,58 +127,28 @@ struct LiveView: View {
|
||
| 73 | 127 |
value: meter.primaryTemperatureDescription, |
| 74 | 128 |
range: temperatureRange() |
| 75 | 129 |
) |
| 76 |
- } |
|
| 77 | 130 |
|
| 78 |
- if shouldShowSecondaryDetails {
|
|
| 79 |
- Group {
|
|
| 80 |
- if compactLayout {
|
|
| 81 |
- HStack(spacing: 12) {
|
|
| 82 |
- if meter.loadResistance > 0 {
|
|
| 83 |
- secondaryDetailChip( |
|
| 84 |
- title: "Load", |
|
| 85 |
- value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
|
| 86 |
- symbol: "cable.connector", |
|
| 87 |
- color: .yellow |
|
| 88 |
- ) |
|
| 89 |
- } |
|
| 90 |
- |
|
| 91 |
- if shouldShowChargerType {
|
|
| 92 |
- secondaryDetailChip( |
|
| 93 |
- title: "Charger", |
|
| 94 |
- value: meter.chargerTypeDescription, |
|
| 95 |
- symbol: "bolt.badge.checkmark", |
|
| 96 |
- color: .purple |
|
| 97 |
- ) |
|
| 98 |
- } |
|
| 99 |
- } |
|
| 100 |
- } else {
|
|
| 101 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 102 |
- Text("Details")
|
|
| 103 |
- .font(.subheadline.weight(.semibold)) |
|
| 104 |
- .foregroundColor(.secondary) |
|
| 105 |
- |
|
| 106 |
- if meter.loadResistance > 0 {
|
|
| 107 |
- secondaryDetailRow( |
|
| 108 |
- title: "Load", |
|
| 109 |
- value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
|
| 110 |
- symbol: "cable.connector", |
|
| 111 |
- color: .yellow |
|
| 112 |
- ) |
|
| 113 |
- } |
|
| 114 |
- |
|
| 115 |
- if shouldShowChargerType {
|
|
| 116 |
- secondaryDetailRow( |
|
| 117 |
- title: "Charger", |
|
| 118 |
- value: meter.chargerTypeDescription, |
|
| 119 |
- symbol: "bolt.badge.checkmark", |
|
| 120 |
- color: .purple |
|
| 121 |
- ) |
|
| 122 |
- } |
|
| 123 |
- } |
|
| 124 |
- } |
|
| 131 |
+ liveMetricCard( |
|
| 132 |
+ title: "Load", |
|
| 133 |
+ customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)), |
|
| 134 |
+ color: .yellow, |
|
| 135 |
+ value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
|
| 136 |
+ detailText: "Measured resistance" |
|
| 137 |
+ ) |
|
| 138 |
+ |
|
| 139 |
+ if shouldShowChargerTile {
|
|
| 140 |
+ liveMetricCard( |
|
| 141 |
+ title: "Charger", |
|
| 142 |
+ symbol: "bolt.badge.checkmark", |
|
| 143 |
+ color: .purple, |
|
| 144 |
+ value: meter.chargerTypeDescription, |
|
| 145 |
+ detailText: chargerTypeDetailText, |
|
| 146 |
+ valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold), |
|
| 147 |
+ valueLineLimit: 2, |
|
| 148 |
+ valueMonospacedDigits: false, |
|
| 149 |
+ valueMinimumScaleFactor: 0.70 |
|
| 150 |
+ ) |
|
| 125 | 151 |
} |
| 126 |
- .padding(compactLayout ? 14 : 18) |
|
| 127 |
- .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
|
| 128 | 152 |
} |
| 129 | 153 |
} |
| 130 | 154 |
.frame(maxWidth: .infinity, alignment: .topLeading) |
@@ -153,12 +177,12 @@ struct LiveView: View {
|
||
| 153 | 177 |
) |
| 154 | 178 |
} |
| 155 | 179 |
|
| 156 |
- private var shouldShowSecondaryDetails: Bool {
|
|
| 157 |
- meter.loadResistance > 0 || shouldShowChargerType |
|
| 180 |
+ private var shouldShowChargerTile: Bool {
|
|
| 181 |
+ meter.supportsChargerDetection |
|
| 158 | 182 |
} |
| 159 | 183 |
|
| 160 |
- private var shouldShowChargerType: Bool {
|
|
| 161 |
- meter.supportsChargerDetection && meter.chargerTypeDescription != "Unknown" |
|
| 184 |
+ private var chargerTypeDetailText: String {
|
|
| 185 |
+ meter.chargerTypeDescription == "Unknown" ? "No charging profile detected" : "Detected charging profile" |
|
| 162 | 186 |
} |
| 163 | 187 |
|
| 164 | 188 |
private var usesExpandedCompactLayout: Bool {
|
@@ -175,19 +199,30 @@ struct LiveView: View {
|
||
| 175 | 199 |
|
| 176 | 200 |
private func liveMetricCard( |
| 177 | 201 |
title: String, |
| 178 |
- symbol: String, |
|
| 202 |
+ symbol: String? = nil, |
|
| 203 |
+ customSymbol: AnyView? = nil, |
|
| 179 | 204 |
color: Color, |
| 180 | 205 |
value: String, |
| 181 | 206 |
range: MetricRange? = nil, |
| 182 |
- detailText: String? = nil |
|
| 207 |
+ detailText: String? = nil, |
|
| 208 |
+ valueFont: Font? = nil, |
|
| 209 |
+ valueLineLimit: Int = 1, |
|
| 210 |
+ valueMonospacedDigits: Bool = true, |
|
| 211 |
+ valueMinimumScaleFactor: CGFloat = 0.85 |
|
| 183 | 212 |
) -> some View {
|
| 184 | 213 |
VStack(alignment: .leading, spacing: 10) {
|
| 185 | 214 |
HStack(spacing: compactLayout ? 8 : 10) {
|
| 186 |
- Image(systemName: symbol) |
|
| 187 |
- .font(.system(size: compactLayout ? 14 : 15, weight: .semibold)) |
|
| 188 |
- .foregroundColor(color) |
|
| 215 |
+ Group {
|
|
| 216 |
+ if let customSymbol {
|
|
| 217 |
+ customSymbol |
|
| 218 |
+ } else if let symbol {
|
|
| 219 |
+ Image(systemName: symbol) |
|
| 220 |
+ .font(.system(size: compactLayout ? 14 : 15, weight: .semibold)) |
|
| 221 |
+ .foregroundColor(color) |
|
| 222 |
+ } |
|
| 223 |
+ } |
|
| 189 | 224 |
.frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34) |
| 190 |
- .background(Circle().fill(color.opacity(0.12))) |
|
| 225 |
+ .background(Circle().fill(color.opacity(0.12))) |
|
| 191 | 226 |
|
| 192 | 227 |
Text(title) |
| 193 | 228 |
.font((compactLayout ? Font.caption : .subheadline).weight(.semibold)) |
@@ -197,9 +232,17 @@ struct LiveView: View {
|
||
| 197 | 232 |
Spacer(minLength: 0) |
| 198 | 233 |
} |
| 199 | 234 |
|
| 200 |
- Text(value) |
|
| 201 |
- .font(.system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold)) |
|
| 202 |
- .monospacedDigit() |
|
| 235 |
+ Group {
|
|
| 236 |
+ if valueMonospacedDigits {
|
|
| 237 |
+ Text(value) |
|
| 238 |
+ .monospacedDigit() |
|
| 239 |
+ } else {
|
|
| 240 |
+ Text(value) |
|
| 241 |
+ } |
|
| 242 |
+ } |
|
| 243 |
+ .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold)) |
|
| 244 |
+ .lineLimit(valueLineLimit) |
|
| 245 |
+ .minimumScaleFactor(valueMinimumScaleFactor) |
|
| 203 | 246 |
|
| 204 | 247 |
if shouldShowMetricRange {
|
| 205 | 248 |
if let range {
|
@@ -221,52 +264,6 @@ struct LiveView: View {
|
||
| 221 | 264 |
.meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12) |
| 222 | 265 |
} |
| 223 | 266 |
|
| 224 |
- private func secondaryDetailRow( |
|
| 225 |
- title: String, |
|
| 226 |
- value: String, |
|
| 227 |
- symbol: String, |
|
| 228 |
- color: Color |
|
| 229 |
- ) -> some View {
|
|
| 230 |
- HStack(spacing: 12) {
|
|
| 231 |
- Image(systemName: symbol) |
|
| 232 |
- .foregroundColor(color) |
|
| 233 |
- .frame(width: 28) |
|
| 234 |
- Text(title) |
|
| 235 |
- .foregroundColor(.secondary) |
|
| 236 |
- Spacer() |
|
| 237 |
- Text(value) |
|
| 238 |
- .fontWeight(.semibold) |
|
| 239 |
- .multilineTextAlignment(.trailing) |
|
| 240 |
- } |
|
| 241 |
- .font(.footnote) |
|
| 242 |
- } |
|
| 243 |
- |
|
| 244 |
- private func secondaryDetailChip( |
|
| 245 |
- title: String, |
|
| 246 |
- value: String, |
|
| 247 |
- symbol: String, |
|
| 248 |
- color: Color |
|
| 249 |
- ) -> some View {
|
|
| 250 |
- HStack(spacing: 10) {
|
|
| 251 |
- Image(systemName: symbol) |
|
| 252 |
- .foregroundColor(color) |
|
| 253 |
- .frame(width: 22, height: 22) |
|
| 254 |
- .background(Circle().fill(color.opacity(0.12))) |
|
| 255 |
- |
|
| 256 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 257 |
- Text(title) |
|
| 258 |
- .foregroundColor(.secondary) |
|
| 259 |
- Text(value) |
|
| 260 |
- .fontWeight(.semibold) |
|
| 261 |
- .lineLimit(1) |
|
| 262 |
- } |
|
| 263 |
- |
|
| 264 |
- Spacer(minLength: 0) |
|
| 265 |
- } |
|
| 266 |
- .font(.caption) |
|
| 267 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 268 |
- } |
|
| 269 |
- |
|
| 270 | 267 |
private func metricRangeTable(_ range: MetricRange) -> some View {
|
| 271 | 268 |
VStack(alignment: .leading, spacing: 4) {
|
| 272 | 269 |
HStack(spacing: 12) {
|