1 contributor
//
// 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<String> = ["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<Int> {
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..<xLabels {
let value = context.xAxisLabel(for: labelIndex, of: xLabels)
let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: geometry.size.height))
}
}
.stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
}
}
}