// // MeterLiveContentView.swift // USB Meter // // Created by Bogdan Timofte on 09/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI struct MeterLiveContentView: View { @EnvironmentObject private var meter: Meter var compactLayout: Bool = false var availableSize: CGSize? = nil var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Live Data") .font(.headline) Spacer() statusBadge } MeterInfoCardView(title: "Detected Meter", tint: .indigo) { MeterInfoRowView(label: "Name", value: meter.name.isEmpty ? "Meter" : meter.name) MeterInfoRowView(label: "Model", value: meter.deviceModelSummary) MeterInfoRowView(label: "Advertised Model", value: meter.modelString) MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description) MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen)) MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt)) } .frame(maxWidth: .infinity, alignment: .leading) LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) { if shouldShowVoltageCard { liveMetricCard( title: "Voltage", symbol: "bolt.fill", color: .green, value: "\(meter.voltage.format(decimalDigits: 3)) V", range: metricRange( min: meter.measurements.voltage.context.minValue, max: meter.measurements.voltage.context.maxValue, unit: "V" ) ) } if shouldShowCurrentCard { liveMetricCard( title: "Current", symbol: "waveform.path.ecg", color: .blue, value: "\(meter.current.format(decimalDigits: 3)) A", range: metricRange( min: meter.measurements.current.context.minValue, max: meter.measurements.current.context.maxValue, unit: "A" ) ) } if shouldShowPowerCard { liveMetricCard( title: "Power", symbol: "flame.fill", color: .pink, value: "\(meter.power.format(decimalDigits: 3)) W", range: metricRange( min: meter.measurements.power.context.minValue, max: meter.measurements.power.context.maxValue, unit: "W" ) ) } if shouldShowTemperatureCard { liveMetricCard( title: "Temperature", symbol: "thermometer.medium", color: .orange, value: meter.primaryTemperatureDescription, range: temperatureRange() ) } if shouldShowLoadCard { liveMetricCard( title: "Load", customSymbol: AnyView(LoadResistanceIconView(color: .yellow)), color: .yellow, value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}", detailText: "Measured resistance" ) } liveMetricCard( title: "RSSI", symbol: "dot.radiowaves.left.and.right", color: .mint, value: "\(meter.btSerial.averageRSSI) dBm", range: MeterLiveMetricRange( minLabel: "Min", maxLabel: "Max", minValue: "\(meter.btSerial.minRSSI) dBm", maxValue: "\(meter.btSerial.maxRSSI) dBm" ), valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold) ) if meter.supportsChargerDetection && hasLiveMetrics { liveMetricCard( title: "Detected Charger", symbol: "powerplug.fill", color: .indigo, value: meter.chargerTypeDescription, detailText: "Source handshake", valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold), valueLineLimit: 2, valueMonospacedDigits: false, valueMinimumScaleFactor: 0.72 ) } } } .frame(maxWidth: .infinity, alignment: .topLeading) } private var hasLiveMetrics: Bool { meter.operationalState == .dataIsAvailable } private var shouldShowVoltageCard: Bool { hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite } private var shouldShowCurrentCard: Bool { hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite } private var shouldShowPowerCard: Bool { hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite } private var shouldShowTemperatureCard: Bool { hasLiveMetrics && meter.displayedTemperatureValue.isFinite } private var shouldShowLoadCard: Bool { hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0 } private var liveMetricColumns: [GridItem] { if compactLayout { return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3) } return [GridItem(.flexible()), GridItem(.flexible())] } private var statusBadge: some View { Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting") .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary) .meterCard( tint: meter.operationalState == .dataIsAvailable ? .green : .secondary, fillOpacity: 0.12, strokeOpacity: 0.16, cornerRadius: 999 ) } private var showsCompactMetricRange: Bool { compactLayout && (availableSize?.height ?? 0) >= 380 } private var shouldShowMetricRange: Bool { !compactLayout || showsCompactMetricRange } private func liveMetricCard( title: String, symbol: String? = nil, customSymbol: AnyView? = nil, color: Color, value: String, range: MeterLiveMetricRange? = nil, detailText: String? = nil, valueFont: Font? = nil, valueLineLimit: Int = 1, valueMonospacedDigits: Bool = true, valueMinimumScaleFactor: CGFloat = 0.85 ) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: compactLayout ? 8 : 10) { Group { if let customSymbol { customSymbol } else if let symbol { Image(systemName: symbol) .font(.system(size: compactLayout ? 14 : 15, weight: .semibold)) .foregroundColor(color) } } .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34) .background(Circle().fill(color.opacity(0.12))) Text(title) .font((compactLayout ? Font.caption : .subheadline).weight(.semibold)) .foregroundColor(.secondary) .lineLimit(1) Spacer(minLength: 0) } Group { if valueMonospacedDigits { Text(value) .monospacedDigit() } else { Text(value) } } .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold)) .lineLimit(valueLineLimit) .minimumScaleFactor(valueMinimumScaleFactor) if shouldShowMetricRange { if let range { metricRangeTable(range) } else if let detailText, !detailText.isEmpty { Text(detailText) .font(.caption) .foregroundColor(.secondary) .lineLimit(2) } } } .frame( maxWidth: .infinity, minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128, alignment: .leading ) .padding(compactLayout ? 12 : 16) .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12) } private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 12) { Text(range.minLabel) Spacer(minLength: 0) Text(range.maxLabel) } .font(.caption2.weight(.semibold)) .foregroundColor(.secondary) HStack(spacing: 12) { Text(range.minValue) .monospacedDigit() Spacer(minLength: 0) Text(range.maxValue) .monospacedDigit() } .font(.caption.weight(.medium)) .foregroundColor(.primary) } } private func metricRange(min: Double, max: Double, unit: String) -> MeterLiveMetricRange? { guard min.isFinite, max.isFinite else { return nil } return MeterLiveMetricRange( minLabel: "Min", maxLabel: "Max", minValue: "\(min.format(decimalDigits: 3)) \(unit)", maxValue: "\(max.format(decimalDigits: 3)) \(unit)" ) } private func temperatureRange() -> MeterLiveMetricRange? { let value = meter.primaryTemperatureDescription guard !value.isEmpty else { return nil } return MeterLiveMetricRange( minLabel: "Min", maxLabel: "Max", minValue: value, maxValue: value ) } private func meterHistoryText(for date: Date?) -> String { guard let date else { return "Never" } return date.format(as: "yyyy-MM-dd HH:mm") } }