// // 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 appData: AppData @EnvironmentObject private var meter: Meter @State private var powerAverageSheetVisibility = false @State private var energyProjectionSheetVisibility = 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 shouldShowEnergyCard { liveMetricCard( title: "Accumulated Energy", symbol: "battery.100.bolt", color: .teal, value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh", detailText: "Tap for monthly and yearly projections", action: { energyProjectionSheetVisibility = 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: $energyProjectionSheetVisibility) { EnergyProjectionSheetView(visibility: $energyProjectionSheetVisibility) .environmentObject(meter.measurements) .environmentObject(meter) } .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 shouldShowEnergyCard: Bool { hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite } private var shouldShowTemperatureCard: Bool { hasLiveMetrics && meter.displayedTemperatureValue.isFinite } private var liveBufferedEnergyValue: Double { meter.measurements.energy.samplePoints.last?.value ?? 0 } 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) { HStack(spacing: 8) { Text("Power Average") .font(.system(.title3, design: .rounded).weight(.bold)) ContextInfoButton( title: "Power Average", message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window." ) } } .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", infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.", tint: .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) { HStack(spacing: 8) { Text("RSSI History") .font(.system(.title3, design: .rounded).weight(.bold)) ContextInfoButton( title: "RSSI History", message: "Signal strength captured over time while the meter stays connected." ) } } .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) TimeSeriesChart(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 { TimeSeriesChartHorizontalGuides( context: context, labelCount: yLabels, strokeColor: Color.secondary.opacity(0.30), lineWidth: 0.8 ) } private func rssiVerticalGuides(context: ChartContext) -> some View { TimeSeriesChartVerticalGuides( context: context, labelCount: xLabels, strokeColor: Color.secondary.opacity(0.26), strokeStyle: StrokeStyle(lineWidth: 0.8, dash: [4, 4]) ) } } private struct EnergyProjectionSheetView: View { @EnvironmentObject private var measurements: Measurements @EnvironmentObject private var meter: Meter @Binding var visibility: Bool @State private var selectedProjectionMethodID: String = "" var body: some View { let snapshot = measurements.energyProjectionSnapshot() let projectionVariants = measurements.energyProjectionVariants() let projectionVariantIDs = projectionVariants.map(\.id) let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants) NavigationView { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text("Energy Projections") .font(.system(.title3, design: .rounded).weight(.bold)) ContextInfoButton( title: "Energy Projections", message: "Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data." ) } } .padding(18) .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24) MeterInfoCardView(title: "Current Session", tint: meter.color) { if let snapshot { MeterInfoRowView( label: "Accumulated Energy", value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh" ) MeterInfoRowView( label: "Observed Interval", value: observedIntervalText(snapshot.observedDuration) ) MeterInfoRowView( label: "Buffered Samples", value: "\(snapshot.sampleCount)" ) MeterInfoRowView( label: "Average Power", value: averagePowerText(snapshot.averagePower) ) } else { Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.") .font(.footnote) .foregroundColor(.secondary) } } MeterInfoCardView( title: "Projection Method", infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.", tint: .teal ) { if projectionVariants.isEmpty { Text("No projection methods available yet.") .font(.footnote) .foregroundColor(.secondary) } else { VStack(alignment: .leading, spacing: 14) { Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) { ForEach(projectionVariants) { variant in Text(variant.title).tag(variant.id) } } .pickerStyle(.menu) if let selectedProjectionVariant { projectionVariantView(selectedProjectionVariant) } } } } } .padding() .padding(.top, 8) } .background( LinearGradient( colors: [.teal.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Energy") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { visibility.toggle() } } } } .navigationViewStyle(StackNavigationViewStyle()) .onAppear { updateSelectedProjectionMethod(with: projectionVariants) } .onChange(of: projectionVariantIDs) { _ in updateSelectedProjectionMethod(with: projectionVariants) } } private func projectionRow(title: String, value: String) -> some View { MeterInfoRowView(label: title, value: value) } private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View { VStack(alignment: .leading, spacing: 8) { Text(variant.title) .font(.subheadline.weight(.semibold)) projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration)) projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy)) projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower)) projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy)) projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy)) } .padding(.bottom, 2) } private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? { if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) { return selectedVariant } return variants.last } private func selectedProjectionMethodBinding( for variants: [Measurements.EnergyProjectionVariant] ) -> Binding { Binding( get: { resolvedProjectionVariant(from: variants)?.id ?? "" }, set: { newValue in selectedProjectionMethodID = newValue } ) } private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) { guard !variants.isEmpty else { selectedProjectionMethodID = "" return } if variants.contains(where: { $0.id == selectedProjectionMethodID }) { return } selectedProjectionMethodID = variants.last?.id ?? "" } private func observedIntervalText(_ duration: TimeInterval) -> String { guard duration > 0 else { return "Insufficient data" } let totalSeconds = Int(duration.rounded()) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return "\(hours)h \(minutes)m" } if minutes > 0 { return "\(minutes)m \(seconds)s" } return "\(seconds)s" } private func averagePowerText(_ averagePower: Double?) -> String { guard let averagePower, averagePower.isFinite else { return "Insufficient data" } return "\(averagePower.format(decimalDigits: 3)) W" } private func averagePowerText(_ averagePower: Double) -> String { averagePowerText(Optional(averagePower)) } private func energyText(_ energy: Double) -> String { if energy >= 1000 { return "\((energy / 1000).format(decimalDigits: 3)) kWh" } return "\(energy.format(decimalDigits: 3)) Wh" } private func projectedEnergyText(_ energy: Double?) -> String { guard let energy, energy.isFinite else { return "Insufficient data" } if energy >= 1000 { return "\((energy / 1000).format(decimalDigits: 3)) kWh" } return "\(energy.format(decimalDigits: 1)) Wh" } }