// // 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 @State private var powerAverageSheetVisibility = false @State private var rssiHistorySheetVisibility = false 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" ), action: { powerAverageSheetVisibility = true } ) } if shouldShowTemperatureCard { liveMetricCard( title: "Temperature", symbol: "thermometer.medium", color: .orange, value: meter.primaryTemperatureDescription, range: temperatureRange( min: meter.measurements.temperature.context.minValue, max: meter.measurements.temperature.context.maxValue ) ) } 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: metricRange( min: meter.measurements.rssi.context.minValue, max: meter.measurements.rssi.context.maxValue, unit: "dBm", decimalDigits: 0 ), valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold), action: { rssiHistorySheetVisibility = true } ) 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) .sheet(isPresented: $powerAverageSheetVisibility) { PowerAverageSheetView(visibility: $powerAverageSheetVisibility) .environmentObject(meter.measurements) } .sheet(isPresented: $rssiHistorySheetVisibility) { RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility) .environmentObject(meter.measurements) } } 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, action: (() -> Void)? = nil ) -> some View { let cardContent = 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) if let action { return AnyView( Button(action: action) { cardContent } .buttonStyle(.plain) ) } return AnyView(cardContent) } 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, decimalDigits: Int = 3) -> MeterLiveMetricRange? { guard min.isFinite, max.isFinite else { return nil } return MeterLiveMetricRange( minLabel: "Min", maxLabel: "Max", minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)", maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)" ) } private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? { guard min.isFinite, max.isFinite else { return nil } let unitSuffix = temperatureUnitSuffix() return MeterLiveMetricRange( minLabel: "Min", maxLabel: "Max", minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)", maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)" ) } private func meterHistoryText(for date: Date?) -> String { guard let date else { return "Never" } return date.format(as: "yyyy-MM-dd HH:mm") } private func temperatureUnitSuffix() -> String { if meter.supportsManualTemperatureUnitSelection { return "°" } let locale = Locale.autoupdatingCurrent if #available(iOS 16.0, *) { switch locale.measurementSystem { case .us: return "°F" default: return "°C" } } let regionCode = locale.regionCode ?? "" let fahrenheitRegions: Set = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"] return fahrenheitRegions.contains(regionCode) ? "°F" : "°C" } } private struct PowerAverageSheetView: View { @EnvironmentObject private var measurements: Measurements @Binding var visibility: Bool @State private var selectedSampleCount: Int = 20 var body: some View { let bufferedSamples = measurements.powerSampleCount() NavigationView { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { Text("Power Average") .font(.system(.title3, design: .rounded).weight(.bold)) Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) MeterInfoCardView(title: "Average Calculator", tint: .pink) { if bufferedSamples == 0 { Text("No power samples are available yet.") .font(.footnote) .foregroundColor(.secondary) } else { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { Text("Values used") .font(.subheadline.weight(.semibold)) Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) { ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option) } } .pickerStyle(.menu) } VStack(alignment: .leading, spacing: 6) { Text(averagePowerLabel(bufferedSamples: bufferedSamples)) .font(.system(.title2, design: .rounded).weight(.bold)) .monospacedDigit() Text("Buffered samples: \(bufferedSamples)") .font(.caption) .foregroundColor(.secondary) } } } } MeterInfoCardView(title: "Buffer Actions", tint: .secondary) { Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.") .font(.footnote) .foregroundColor(.secondary) Button("Reset Buffer") { measurements.resetSeries() selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false)) } .foregroundColor(.red) } } .padding() } .background( LinearGradient( colors: [.pink.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationBarItems( leading: Button("Done") { visibility.toggle() } ) .navigationBarTitle("Power", displayMode: .inline) } .navigationViewStyle(StackNavigationViewStyle()) .onAppear { selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples) } .onChange(of: bufferedSamples) { newValue in selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue)) } } private func availableSampleOptions(bufferedSamples: Int) -> [Int] { guard bufferedSamples > 0 else { return [] } let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples } return (filtered + [bufferedSamples]).sorted() } private func defaultSampleCount(bufferedSamples: Int) -> Int { guard bufferedSamples > 0 else { return 20 } return min(20, bufferedSamples) } private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding { Binding( get: { let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples) guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) } if availableOptions.contains(selectedSampleCount) { return selectedSampleCount } return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples)) }, set: { newValue in selectedSampleCount = newValue } ) } private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String { if option == bufferedSamples { return "All (\(option))" } return "\(option) values" } private func averagePowerLabel(bufferedSamples: Int) -> String { guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else { return "No data" } let effectiveSampleCount = min(selectedSampleCount, bufferedSamples) return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))" } } private struct RSSIHistorySheetView: View { @EnvironmentObject private var measurements: Measurements @Binding var visibility: Bool private let xLabels: Int = 4 private let yLabels: Int = 4 var body: some View { let points = measurements.rssi.points let samplePoints = measurements.rssi.samplePoints let chartContext = buildChartContext(for: samplePoints) NavigationView { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { Text("RSSI History") .font(.system(.title3, design: .rounded).weight(.bold)) Text("Signal strength captured over time while the meter stays connected.") .font(.footnote) .foregroundColor(.secondary) } .padding(18) .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24) if samplePoints.isEmpty { Text("No RSSI samples have been captured yet.") .font(.footnote) .foregroundColor(.secondary) .padding(18) .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20) } else { MeterInfoCardView(title: "Signal Chart", tint: .mint) { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm") signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm") signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm") } HStack(spacing: 8) { rssiYAxisView(context: chartContext) .frame(width: 52, height: 220) ZStack { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color.primary.opacity(0.05)) RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(Color.secondary.opacity(0.16), lineWidth: 1) rssiHorizontalGuides(context: chartContext) rssiVerticalGuides(context: chartContext) Chart(points: points, context: chartContext, strokeColor: .mint) .opacity(0.82) } .frame(maxWidth: .infinity) .frame(height: 220) } rssiXAxisLabelsView(context: chartContext) .frame(height: 28) } } } } .padding() } .background( LinearGradient( colors: [.mint.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationBarItems( leading: Button("Done") { visibility.toggle() } ) .navigationBarTitle("RSSI", displayMode: .inline) } .navigationViewStyle(StackNavigationViewStyle()) } private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext { let context = ChartContext() let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date()) let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60) let minimumValue = samplePoints.map(\.value).min() ?? -100 let maximumValue = samplePoints.map(\.value).max() ?? -40 let padding = max((maximumValue - minimumValue) * 0.12, 4) context.setBounds( xMin: CGFloat(lowerBound.timeIntervalSince1970), xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)), yMin: CGFloat(minimumValue - padding), yMax: CGFloat(maximumValue + padding) ) return context } private func signalSummaryChip(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) Text(value) .font(.subheadline.weight(.bold)) .monospacedDigit() } .padding(.horizontal, 12) .padding(.vertical, 10) .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) } private func rssiXAxisLabelsView(context: ChartContext) -> some View { let labels = (1...xLabels).map { Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss") } return HStack { ForEach(Array(labels.enumerated()), id: \.offset) { item in Text(item.element) .font(.caption2.weight(.semibold)) .monospacedDigit() .frame(maxWidth: .infinity) } } .foregroundColor(.secondary) } private func rssiYAxisView(context: ChartContext) -> some View { VStack(spacing: 0) { ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in Spacer(minLength: 0) Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))") .font(.caption2.weight(.semibold)) .monospacedDigit() .foregroundColor(.primary) Spacer(minLength: 0) } } .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.mint.opacity(0.12)) ) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.mint.opacity(0.20), lineWidth: 1) ) } private func rssiHorizontalGuides(context: ChartContext) -> some View { GeometryReader { geometry in Path { path in for labelIndex in 1...yLabels { let value = context.yAxisLabel(for: labelIndex, of: yLabels) let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value)) let y = context.placeInRect(point: anchorPoint).y * geometry.size.height path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y)) } } .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8) } } private func rssiVerticalGuides(context: ChartContext) -> some View { GeometryReader { geometry in Path { path in for labelIndex in 2..