mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Fix/2523 redundant soil metrics (#2556)
Signed-off-by: DaneEvans <dane@goneepic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
edcf0f11eb
commit
1ba70ca547
16 changed files with 823 additions and 449 deletions
|
|
@ -150,18 +150,37 @@ data class DeviceMetrics(
|
|||
@Parcelize
|
||||
data class EnvironmentMetrics(
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val temperature: Float,
|
||||
val relativeHumidity: Float,
|
||||
val soilTemperature: Float,
|
||||
val soilMoisture: Int,
|
||||
val barometricPressure: Float,
|
||||
val gasResistance: Float,
|
||||
val voltage: Float,
|
||||
val current: Float,
|
||||
val iaq: Int,
|
||||
val temperature: Float?,
|
||||
val relativeHumidity: Float?,
|
||||
val soilTemperature: Float?,
|
||||
val soilMoisture: Int?,
|
||||
val barometricPressure: Float?,
|
||||
val gasResistance: Float?,
|
||||
val voltage: Float?,
|
||||
val current: Float?,
|
||||
val iaq: Int?,
|
||||
val lux: Float? = null,
|
||||
val uvLux: Float? = null,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
|
||||
fun fromTelemetryProto(proto: TelemetryProtos.EnvironmentMetrics, time: Int): EnvironmentMetrics =
|
||||
EnvironmentMetrics(
|
||||
temperature = proto.temperature.takeIf { proto.hasTemperature() && !it.isNaN() },
|
||||
relativeHumidity =
|
||||
proto.relativeHumidity.takeIf { proto.hasRelativeHumidity() && !it.isNaN() && it != 0.0f },
|
||||
soilTemperature = proto.soilTemperature.takeIf { proto.hasSoilTemperature() && !it.isNaN() },
|
||||
soilMoisture = proto.soilMoisture.takeIf { proto.hasSoilMoisture() && it != Int.MIN_VALUE },
|
||||
barometricPressure = proto.barometricPressure.takeIf { proto.hasBarometricPressure() && !it.isNaN() },
|
||||
gasResistance = proto.gasResistance.takeIf { proto.hasGasResistance() && !it.isNaN() },
|
||||
voltage = proto.voltage.takeIf { proto.hasVoltage() && !it.isNaN() },
|
||||
current = proto.current.takeIf { proto.hasCurrent() && !it.isNaN() },
|
||||
iaq = proto.iaq.takeIf { proto.hasIaq() && it != Int.MIN_VALUE },
|
||||
lux = proto.lux.takeIf { proto.hasLux() && !it.isNaN() },
|
||||
uvLux = proto.uvLux.takeIf { proto.hasUvLux() && !it.isNaN() },
|
||||
time = time,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class Converters : Logging {
|
|||
TelemetryProtos.Telemetry.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToTelemetry TypeConverter error:", ex)
|
||||
TelemetryProtos.Telemetry.getDefaultInstance()
|
||||
TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object
|
||||
}
|
||||
|
||||
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
|
||||
|
|
|
|||
|
|
@ -50,6 +50,44 @@ constructor(
|
|||
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
|
||||
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
|
||||
.toBuilder()
|
||||
.apply {
|
||||
// Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no data
|
||||
if (!environmentMetrics.hasTemperature()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasRelativeHumidity()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasSoilTemperature()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasBarometricPressure()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasGasResistance()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasVoltage()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasCurrent()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasLux()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build()
|
||||
}
|
||||
if (!environmentMetrics.hasUvLux()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build()
|
||||
}
|
||||
|
||||
// Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data
|
||||
if (!environmentMetrics.hasIaq()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build()
|
||||
}
|
||||
if (!environmentMetrics.hasSoilMoisture()) {
|
||||
environmentMetrics = environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build()
|
||||
}
|
||||
}
|
||||
.setTime((log.received_date / MILLIS_TO_SECONDS).toInt())
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ data class NodeEntity(
|
|||
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
|
||||
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
|
||||
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
|
||||
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
|
||||
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
|
||||
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
|
||||
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||
|
|
@ -192,17 +192,9 @@ data class NodeEntity(
|
|||
),
|
||||
channel = channel,
|
||||
environmentMetrics =
|
||||
EnvironmentMetrics(
|
||||
time = environmentTelemetry.time,
|
||||
temperature = environmentMetrics.temperature,
|
||||
relativeHumidity = environmentMetrics.relativeHumidity,
|
||||
soilTemperature = environmentMetrics.soilTemperature,
|
||||
soilMoisture = environmentMetrics.soilMoisture,
|
||||
barometricPressure = environmentMetrics.barometricPressure,
|
||||
gasResistance = environmentMetrics.gasResistance,
|
||||
voltage = environmentMetrics.voltage,
|
||||
current = environmentMetrics.current,
|
||||
iaq = environmentMetrics.iaq,
|
||||
EnvironmentMetrics.fromTelemetryProto(
|
||||
environmentTelemetry.environmentMetrics,
|
||||
environmentTelemetry.time,
|
||||
),
|
||||
hopsAway = hopsAway,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,35 +18,50 @@
|
|||
package com.geeksville.mesh.model
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||
import com.geeksville.mesh.TelemetryProtos
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.InfantryBlue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.LightGreen
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Yellow
|
||||
import com.geeksville.mesh.util.UnitConversions
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class Environment(val color: Color) {
|
||||
TEMPERATURE(Red) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.temperature
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
|
||||
},
|
||||
HUMIDITY(InfantryBlue) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.relativeHumidity
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
|
||||
},
|
||||
SOIL_TEMPERATURE(Pink) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilTemperature
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature
|
||||
},
|
||||
SOIL_MOISTURE(Purple) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilMoisture.toFloat()
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
|
||||
telemetry.environmentMetrics.soilMoisture?.toFloat()
|
||||
},
|
||||
IAQ(Color.Green) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.iaq.toFloat()
|
||||
BAROMETRIC_PRESSURE(Green) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
|
||||
},
|
||||
BAROMETRIC_PRESSURE(Orange) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.barometricPressure
|
||||
GAS_RESISTANCE(Yellow) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
|
||||
},
|
||||
IAQ(Magenta) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
|
||||
},
|
||||
LUX(LightGreen) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
|
||||
},
|
||||
UV_LUX(Orange) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux
|
||||
}, ;
|
||||
|
||||
abstract fun getValue(telemetry: Telemetry): Float
|
||||
abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float?
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,14 +72,14 @@ enum class Environment(val color: Color) {
|
|||
* @param times [Pair] with the oldest and newest times in that order
|
||||
*/
|
||||
data class EnvironmentGraphingData(
|
||||
val metrics: List<Telemetry>,
|
||||
val metrics: List<TelemetryProtos.Telemetry>,
|
||||
val shouldPlot: List<Boolean>,
|
||||
val leftMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val rightMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val times: Pair<Int, Int> = Pair(0, 0),
|
||||
)
|
||||
|
||||
data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emptyList()) {
|
||||
data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.Telemetry> = emptyList()) {
|
||||
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
|
||||
|
||||
/**
|
||||
|
|
@ -85,96 +100,96 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
|
|||
/* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */
|
||||
val minValues = mutableListOf<Float>()
|
||||
val maxValues = mutableListOf<Float>()
|
||||
val (minTemp, maxTemp) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.temperature },
|
||||
telemetries.maxBy { it.environmentMetrics.temperature },
|
||||
)
|
||||
var minTempValue = minTemp.environmentMetrics.temperature
|
||||
var maxTempValue = maxTemp.environmentMetrics.temperature
|
||||
if (useFahrenheit) {
|
||||
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
|
||||
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
|
||||
}
|
||||
if (minTemp.environmentMetrics.temperature != 0f || maxTemp.environmentMetrics.temperature != 0f) {
|
||||
|
||||
// Temperature
|
||||
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
|
||||
if (temperatures.isNotEmpty()) {
|
||||
var minTempValue = temperatures.minOf { it }
|
||||
var maxTempValue = temperatures.maxOf { it }
|
||||
if (useFahrenheit) {
|
||||
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
|
||||
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
|
||||
}
|
||||
minValues.add(minTempValue)
|
||||
maxValues.add(maxTempValue)
|
||||
shouldPlot[Environment.TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minHumidity, maxHumidity) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.relativeHumidity },
|
||||
telemetries.maxBy { it.environmentMetrics.relativeHumidity },
|
||||
)
|
||||
if (
|
||||
minHumidity.environmentMetrics.relativeHumidity != 0f ||
|
||||
maxHumidity.environmentMetrics.relativeHumidity != 0f
|
||||
) {
|
||||
minValues.add(minHumidity.environmentMetrics.relativeHumidity)
|
||||
maxValues.add(maxHumidity.environmentMetrics.relativeHumidity)
|
||||
// Relative Humidity
|
||||
val humidities =
|
||||
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
|
||||
if (humidities.isNotEmpty()) {
|
||||
minValues.add(humidities.minOf { it })
|
||||
maxValues.add(humidities.maxOf { it })
|
||||
shouldPlot[Environment.HUMIDITY.ordinal] = true
|
||||
}
|
||||
|
||||
var minSoilTemperatureValue = minTemp.environmentMetrics.soilTemperature
|
||||
var maxSoilTemperatureValue = maxTemp.environmentMetrics.soilTemperature
|
||||
if (useFahrenheit) {
|
||||
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
|
||||
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
|
||||
}
|
||||
if (minTemp.environmentMetrics.soilTemperature != 0f || maxTemp.environmentMetrics.soilTemperature != 0f) {
|
||||
// Soil Temperature
|
||||
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
|
||||
if (soilTemperatures.isNotEmpty()) {
|
||||
var minSoilTemperatureValue = soilTemperatures.minOf { it }
|
||||
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
|
||||
if (useFahrenheit) {
|
||||
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
|
||||
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
|
||||
}
|
||||
minValues.add(minSoilTemperatureValue)
|
||||
maxValues.add(maxSoilTemperatureValue)
|
||||
shouldPlot[Environment.SOIL_TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minSoilMoisture, maxSoilMoisture) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.soilMoisture },
|
||||
telemetries.maxBy { it.environmentMetrics.soilMoisture },
|
||||
)
|
||||
val soilMoistureRange = 0..100
|
||||
if (
|
||||
minSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange ||
|
||||
maxSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange
|
||||
) {
|
||||
minValues.add(minSoilMoisture.environmentMetrics.soilMoisture.toFloat())
|
||||
maxValues.add(maxSoilMoisture.environmentMetrics.soilMoisture.toFloat())
|
||||
// Soil Moisture
|
||||
val soilMoistures =
|
||||
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
|
||||
if (soilMoistures.isNotEmpty()) {
|
||||
minValues.add(soilMoistures.minOf { it.toFloat() })
|
||||
maxValues.add(soilMoistures.maxOf { it.toFloat() })
|
||||
shouldPlot[Environment.SOIL_MOISTURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minIAQ, maxIAQ) =
|
||||
Pair(telemetries.minBy { it.environmentMetrics.iaq }, telemetries.maxBy { it.environmentMetrics.iaq })
|
||||
if (minIAQ.environmentMetrics.iaq != 0 || maxIAQ.environmentMetrics.iaq != 0) {
|
||||
minValues.add(minIAQ.environmentMetrics.iaq.toFloat())
|
||||
maxValues.add(maxIAQ.environmentMetrics.iaq.toFloat())
|
||||
// IAQ
|
||||
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
|
||||
if (iaqs.isNotEmpty()) {
|
||||
minValues.add(iaqs.minOf { it.toFloat() })
|
||||
maxValues.add(iaqs.maxOf { it.toFloat() })
|
||||
shouldPlot[Environment.IAQ.ordinal] = true
|
||||
}
|
||||
|
||||
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
|
||||
val max = if (maxValues.isEmpty()) 0f else maxValues.maxOf { it }
|
||||
|
||||
val (minPressure, maxPressure) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.barometricPressure },
|
||||
telemetries.maxBy { it.environmentMetrics.barometricPressure },
|
||||
)
|
||||
if (
|
||||
minPressure.environmentMetrics.barometricPressure != 0.0F &&
|
||||
maxPressure.environmentMetrics.barometricPressure != 0.0F
|
||||
) {
|
||||
// Barometric Pressure
|
||||
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
|
||||
var minPressureValue = 0f
|
||||
var maxPressureValue = 0f
|
||||
if (pressures.isNotEmpty()) {
|
||||
minPressureValue = pressures.minOf { it }
|
||||
maxPressureValue = pressures.maxOf { it }
|
||||
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true
|
||||
}
|
||||
|
||||
// Lux
|
||||
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
|
||||
if (luxValues.isNotEmpty()) {
|
||||
minValues.add(luxValues.minOf { it })
|
||||
maxValues.add(luxValues.maxOf { it })
|
||||
shouldPlot[Environment.LUX.ordinal] = true
|
||||
}
|
||||
|
||||
// UVLux
|
||||
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
|
||||
if (uvLuxValues.isNotEmpty()) {
|
||||
minValues.add(uvLuxValues.minOf { it })
|
||||
maxValues.add(uvLuxValues.maxOf { it })
|
||||
shouldPlot[Environment.UV_LUX.ordinal] = true
|
||||
}
|
||||
|
||||
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
|
||||
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
|
||||
|
||||
val (oldest, newest) = Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time })
|
||||
|
||||
return EnvironmentGraphingData(
|
||||
metrics = telemetries,
|
||||
shouldPlot = shouldPlot.toList(),
|
||||
leftMinMax =
|
||||
Pair(
|
||||
minPressure.environmentMetrics.barometricPressure,
|
||||
maxPressure.environmentMetrics.barometricPressure,
|
||||
),
|
||||
leftMinMax = Pair(minPressureValue, maxPressureValue),
|
||||
rightMinMax = Pair(min, max),
|
||||
times = Pair(oldest.time, newest.time),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -297,7 +297,8 @@ constructor(
|
|||
environmentMetrics =
|
||||
telemetry.filter {
|
||||
it.hasEnvironmentMetrics() &&
|
||||
it.environmentMetrics.relativeHumidity >= 0f &&
|
||||
it.environmentMetrics.hasRelativeHumidity() &&
|
||||
it.environmentMetrics.hasTemperature() &&
|
||||
!it.environmentMetrics.temperature.isNaN()
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ enum class Iaq(val color: Color, val description: String, val range: IntRange) {
|
|||
DangerouslyPolluted(IAQDangerouslyPolluted, "Dangerously Polluted", 501..Int.MAX_VALUE),
|
||||
}
|
||||
|
||||
fun getIaq(iaq: Int): Iaq = when {
|
||||
fun getIaq(iaq: Int): Iaq? = when {
|
||||
iaq == Int.MIN_VALUE -> null
|
||||
iaq in Iaq.Excellent.range -> Iaq.Excellent
|
||||
iaq in Iaq.Good.range -> Iaq.Good
|
||||
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
|
||||
|
|
@ -106,88 +107,96 @@ enum class IaqDisplayMode {
|
|||
|
||||
@Suppress("LongMethod", "UnusedPrivateProperty")
|
||||
@Composable
|
||||
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
|
||||
fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
|
||||
if (iaq == null || iaq == Int.MIN_VALUE) {
|
||||
return
|
||||
}
|
||||
var isLegendOpen by remember { mutableStateOf(false) }
|
||||
val iaqEnum = getIaq(iaq)
|
||||
val iaqEnum = if (iaq != null) getIaq(iaq) else null
|
||||
val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color })
|
||||
|
||||
Column {
|
||||
when (displayMode) {
|
||||
IaqDisplayMode.Pill -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(iaqEnum.color)
|
||||
.width(125.dp)
|
||||
.height(30.dp)
|
||||
.clickable { isLegendOpen = true },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
if (iaqEnum != null) {
|
||||
Column {
|
||||
when (displayMode) {
|
||||
IaqDisplayMode.Pill -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(iaqEnum.color)
|
||||
.width(125.dp)
|
||||
.height(30.dp)
|
||||
.clickable { isLegendOpen = true },
|
||||
) {
|
||||
Text(text = "IAQ $iaq", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Icon(
|
||||
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.air_quality_icon),
|
||||
tint = Color.White,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = "IAQ $iaq", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Icon(
|
||||
imageVector =
|
||||
if (iaqEnum.range.first < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.air_quality_icon),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IaqDisplayMode.Dot -> {
|
||||
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$iaq")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(modifier = Modifier.size(10.dp).background(iaqEnum.color, shape = CircleShape))
|
||||
IaqDisplayMode.Dot -> {
|
||||
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$iaq")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(modifier = Modifier.size(10.dp).background(iaqEnum.color, shape = CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IaqDisplayMode.Text -> {
|
||||
Text(
|
||||
text = getIaqDescriptionWithRange(iaqEnum),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
)
|
||||
}
|
||||
IaqDisplayMode.Text -> {
|
||||
Text(
|
||||
text = getIaqDescriptionWithRange(iaqEnum),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gauge -> {
|
||||
CircularProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
|
||||
strokeWidth = 8.dp,
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Text(text = "$iaq")
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gradient -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
IaqDisplayMode.Gauge -> {
|
||||
CircularProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier.fillMaxWidth().height(20.dp),
|
||||
modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
|
||||
strokeWidth = 8.dp,
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = iaqEnum.description, fontSize = 12.sp)
|
||||
Text(text = "${iaqEnum.description}")
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gradient -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier.fillMaxWidth().height(20.dp),
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = iaqEnum.description, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLegendOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isLegendOpen = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = { IAQScale() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isLegendOpen = false }) { Text(text = stringResource(id = R.string.close)) }
|
||||
},
|
||||
)
|
||||
if (isLegendOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isLegendOpen = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = { IAQScale() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isLegendOpen = false }) {
|
||||
Text(text = stringResource(id = R.string.close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ object IAQColors {
|
|||
|
||||
object GraphColors {
|
||||
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
|
||||
val LightGreen = Color(0xFF4BF0BE)
|
||||
val Purple = Color(0xFF9C27B0)
|
||||
val Pink = Color(red = 255, green = 102, blue = 204)
|
||||
val Orange = Color(0xFFFF8800)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import androidx.compose.ui.text.font.FontStyle
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.size
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.core.IOException
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Environment
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
|
|
@ -78,7 +79,12 @@ private const val DATE_Y = 32f
|
|||
private const val LINE_LIMIT = 4
|
||||
private const val TEXT_PAINT_ALPHA = 192
|
||||
|
||||
data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false)
|
||||
data class LegendData(
|
||||
val nameRes: Int,
|
||||
val color: Color,
|
||||
val isLine: Boolean = false,
|
||||
val environmentMetric: Environment? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChartHeader(amount: Int) {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
|
|||
import com.geeksville.mesh.ui.common.theme.GraphColors.Cyan
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
|
|
@ -79,21 +79,39 @@ import com.geeksville.mesh.util.GraphUtil
|
|||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.plotPoint
|
||||
|
||||
private enum class Device(val color: Color) {
|
||||
BATTERY(Green),
|
||||
CH_UTIL(Magenta),
|
||||
AIR_UTIL(Cyan),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT)
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private enum class Device(val color: Color) {
|
||||
BATTERY(Green) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
|
||||
},
|
||||
CH_UTIL(Magenta) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
|
||||
},
|
||||
AIR_UTIL(Cyan) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx
|
||||
}, ;
|
||||
|
||||
abstract fun getValue(telemetry: Telemetry): Float
|
||||
}
|
||||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true),
|
||||
LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color),
|
||||
LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color),
|
||||
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
|
||||
LegendData(
|
||||
nameRes = R.string.channel_utilization,
|
||||
color = Device.CH_UTIL.color,
|
||||
isLine = false,
|
||||
environmentMetric = null,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.air_utilization,
|
||||
color = Device.AIR_UTIL.color,
|
||||
isLine = false,
|
||||
environmentMetric = null,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -117,7 +135,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
|||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
data.reversed(),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
|
@ -186,7 +204,7 @@ private fun DeviceMetricsChart(
|
|||
*/
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor),
|
||||
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
|
||||
)
|
||||
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||
import com.geeksville.mesh.model.Environment
|
||||
import com.geeksville.mesh.model.EnvironmentGraphingData
|
||||
import com.geeksville.mesh.model.TimeFrame
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val LEGEND_DATA_1 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.temperature,
|
||||
color = Environment.TEMPERATURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.TEMPERATURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.humidity,
|
||||
color = Environment.HUMIDITY.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.HUMIDITY,
|
||||
),
|
||||
)
|
||||
private val LEGEND_DATA_2 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.iaq,
|
||||
color = Environment.IAQ.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.IAQ,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.baro_pressure,
|
||||
color = Environment.BAROMETRIC_PRESSURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.BAROMETRIC_PRESSURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.lux,
|
||||
color = Environment.LUX.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.LUX,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.uv_lux,
|
||||
color = Environment.UV_LUX.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.UV_LUX,
|
||||
),
|
||||
)
|
||||
|
||||
private val LEGEND_DATA_3 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.soil_temperature,
|
||||
color = Environment.SOIL_TEMPERATURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.SOIL_TEMPERATURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.soil_moisture,
|
||||
color = Environment.SOIL_MOISTURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.SOIL_MOISTURE,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) = graphData.times
|
||||
val timeDiff = newest - oldest
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
|
||||
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
|
||||
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
|
||||
val visibleWidthPx = screenWidth * chartWidthRatio
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
BarometricPressureYAxisLabel(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
|
||||
minValue = graphData.leftMinMax.first,
|
||||
maxValue = graphData.leftMinMax.second,
|
||||
)
|
||||
ChartContent(
|
||||
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
|
||||
scrollState = scrollState,
|
||||
dp = dp,
|
||||
oldest = oldest,
|
||||
newest = newest,
|
||||
selectedTime = selectedTime,
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
rightMin = graphData.rightMinMax.first,
|
||||
rightMax = graphData.rightMinMax.second,
|
||||
timeDiff = timeDiff,
|
||||
)
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
MaterialTheme.colorScheme.onSurface,
|
||||
minValue = graphData.rightMinMax.first,
|
||||
maxValue = graphData.rightMinMax.second,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
MetricLegends(graphData = graphData, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("detekt:LongMethod")
|
||||
@Composable
|
||||
private fun MetricPlottingCanvas(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
oldest: Int,
|
||||
timeDiff: Int,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
) {
|
||||
val (pressureMin, pressureMax) = graphData.leftMinMax
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
var min: Float
|
||||
var diff: Float
|
||||
var index: Int
|
||||
var first: Int
|
||||
for (metric in Environment.entries) {
|
||||
if (!shouldPlot[metric.ordinal]) {
|
||||
continue
|
||||
}
|
||||
if (metric == Environment.BAROMETRIC_PRESSURE) {
|
||||
diff = pressureMax - pressureMin
|
||||
min = pressureMin
|
||||
} else { // Reset for other metrics to use rightMin/rightMax
|
||||
min = rightMin
|
||||
diff = rightMax - rightMin
|
||||
}
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
first = index
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val rawValue = metric.getValue(telemetry) // This is Float?
|
||||
|
||||
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
|
||||
// lux.
|
||||
val pointValue =
|
||||
if (rawValue != null && !rawValue.isNaN()) {
|
||||
rawValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
|
||||
// scale group.
|
||||
val currentMin = min
|
||||
// Avoid division by zero if all values in the current y-axis range are the same.
|
||||
val currentDiff = if (diff == 0f) 1f else diff
|
||||
|
||||
val ratio = (pointValue - currentMin) / currentDiff
|
||||
var y = height - (ratio * height)
|
||||
|
||||
// Final check to ensure y is a valid, plottable coordinate.
|
||||
if (y.isNaN() || y.isInfinite()) {
|
||||
y = height // Default to the bottom of the chart if calculation still results in an
|
||||
// invalid number.
|
||||
} else {
|
||||
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
|
||||
}
|
||||
return@createPath y
|
||||
}
|
||||
drawPathWithGradient(
|
||||
path = path,
|
||||
color = metric.color,
|
||||
height = height,
|
||||
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarometricPressureYAxisLabel(
|
||||
modifier: Modifier,
|
||||
shouldPlotBarometricPressure: Boolean,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
) {
|
||||
if (shouldPlotBarometricPressure) {
|
||||
YAxisLabels(
|
||||
modifier = modifier,
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChartContent(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState,
|
||||
dp: Dp,
|
||||
oldest: Int,
|
||||
newest: Int,
|
||||
selectedTime: TimeFrame,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
timeDiff: Int,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
|
||||
|
||||
MetricPlottingCanvas(
|
||||
modifier = modifier.width(dp),
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
selectedTime = selectedTime,
|
||||
oldest = oldest,
|
||||
timeDiff = timeDiff,
|
||||
rightMin = rightMin,
|
||||
rightMax = rightMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
|
||||
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
|
||||
Legend(LEGEND_DATA_3.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
|
||||
Legend(
|
||||
LEGEND_DATA_2.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] },
|
||||
promptInfoDialog = promptInfoDialog,
|
||||
)
|
||||
}
|
||||
|
||||
// private const val LINE_ON = 10f
|
||||
// private const val LINE_OFF = 20f
|
||||
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
// private const val DATE_Y = 32f
|
||||
// private const val LINE_LIMIT = 4
|
||||
// private const val TEXT_PAINT_ALPHA = 192
|
||||
|
|
@ -17,11 +17,7 @@
|
|||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -33,7 +29,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -47,72 +42,27 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.EnvironmentMetrics
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.TelemetryProtos
|
||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Environment
|
||||
import com.geeksville.mesh.model.EnvironmentGraphingData
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.model.TimeFrame
|
||||
import com.geeksville.mesh.ui.common.components.IaqDisplayMode
|
||||
import com.geeksville.mesh.ui.common.components.IndoorAirQuality
|
||||
import com.geeksville.mesh.ui.common.components.OptionLabel
|
||||
import com.geeksville.mesh.ui.common.components.SlidingSelector
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Blue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Yellow
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
|
||||
import com.geeksville.mesh.util.UnitConversions.celsiusToFahrenheit
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private enum class Environment(val color: Color) {
|
||||
TEMPERATURE(Red),
|
||||
RELATIVE_HUMIDITY(Blue),
|
||||
SOIL_TEMPERATURE(Pink),
|
||||
SOIL_MOISTURE(Purple),
|
||||
BAROMETRIC_PRESSURE(Green),
|
||||
GAS_RESISTANCE(Yellow),
|
||||
IAQ(Magenta),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
// EnvironmentMetrics can have 1 or 2 Y-axis labels depending on whether barometric pressure is plotted
|
||||
// We'll calculate this dynamically in the chart function
|
||||
|
||||
private val LEGEND_DATA_1 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.temperature, color = Environment.TEMPERATURE.color, isLine = true),
|
||||
LegendData(nameRes = R.string.humidity, color = Environment.HUMIDITY.color, isLine = true),
|
||||
)
|
||||
private val LEGEND_DATA_2 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.iaq, color = Environment.IAQ.color, isLine = true),
|
||||
LegendData(nameRes = R.string.baro_pressure, color = Environment.BAROMETRIC_PRESSURE.color, isLine = true),
|
||||
)
|
||||
private val LEGEND_DATA_3 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.soil_temperature, color = Environment.SOIL_TEMPERATURE.color, isLine = true),
|
||||
LegendData(nameRes = R.string.soil_moisture, color = Environment.SOIL_MOISTURE.color, isLine = true),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
|
@ -151,7 +101,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
|||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = processedTelemetries.reversed(),
|
||||
graphData = graphData,
|
||||
selectedTimeFrame,
|
||||
selectedTime = selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
|
|
@ -169,230 +119,204 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ConfigurationScreenWidthHeight")
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun EnvironmentMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
private fun TemperatureDisplay(temperature: Float, environmentDisplayFahrenheit: Boolean) {
|
||||
if (!temperature.isNaN()) {
|
||||
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
Text(
|
||||
text = textFormat.format(stringResource(id = R.string.temperature), temperature),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val (oldest, newest) = graphData.times
|
||||
val timeDiff = newest - oldest
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
|
||||
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
|
||||
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
|
||||
val visibleWidthPx = screenWidth * chartWidthRatio
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val (rightMin, rightMax) = graphData.rightMinMax
|
||||
val (pressureMin, pressureMax) = graphData.leftMinMax
|
||||
var min = rightMin
|
||||
var diff = rightMax - rightMin
|
||||
|
||||
Row {
|
||||
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
minValue = pressureMin,
|
||||
maxValue = pressureMax,
|
||||
)
|
||||
@Composable
|
||||
private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
envMetrics.relativeHumidity?.let { humidity ->
|
||||
if (!humidity.isNaN()) {
|
||||
Text(
|
||||
text = "%s %.2f%%".format(stringResource(id = R.string.humidity), humidity),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
envMetrics.barometricPressure?.let { pressure ->
|
||||
if (!pressure.isNaN() && pressure > 0) { // Keep pressure > 0 check
|
||||
Text(
|
||||
text = "%.2f hPa".format(pressure),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier = modifier.width(dp),
|
||||
oldest = oldest,
|
||||
newest = newest,
|
||||
selectedTime.lineInterval(),
|
||||
)
|
||||
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
var index: Int
|
||||
var first: Int
|
||||
for (metric in Environment.entries) {
|
||||
if (!shouldPlot[metric.ordinal]) {
|
||||
continue
|
||||
}
|
||||
if (metric == Environment.BAROMETRIC_PRESSURE) {
|
||||
diff = pressureMax - pressureMin
|
||||
min = pressureMin
|
||||
}
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
first = index
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (metric.getValue(telemetry) - min) / diff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPathWithGradient(
|
||||
path = path,
|
||||
color = metric.color,
|
||||
height = height,
|
||||
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
|
||||
if (
|
||||
envMetrics.soilTemperature != null ||
|
||||
(envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val soilTemperatureTextFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
val soilMoistureTextFormat = "%s %d%%"
|
||||
envMetrics.soilMoisture?.let { soilMoistureValue ->
|
||||
if (soilMoistureValue != Int.MIN_VALUE) {
|
||||
Text(
|
||||
text = soilMoistureTextFormat.format(stringResource(R.string.soil_moisture), soilMoistureValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
envMetrics.soilTemperature?.let { soilTemperature ->
|
||||
if (!soilTemperature.isNaN()) {
|
||||
Text(
|
||||
text =
|
||||
soilTemperatureTextFormat.format(
|
||||
stringResource(R.string.soil_temperature),
|
||||
soilTemperature,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
graphColor,
|
||||
minValue = rightMin,
|
||||
maxValue = rightMax,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Legend(LEGEND_DATA_1, displayInfoIcon = false)
|
||||
Legend(LEGEND_DATA_3, displayInfoIcon = false)
|
||||
Legend(LEGEND_DATA_2, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "MagicNumber")
|
||||
@Composable
|
||||
private fun IaqDisplay(iaqValue: Int) {
|
||||
if (iaqValue != Int.MIN_VALUE) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
/* Air Quality */
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.iaq),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IndoorAirQuality(iaq = iaqValue, displayMode = IaqDisplayMode.Dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
envMetrics.lux?.let { luxValue ->
|
||||
if (!luxValue.isNaN()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.0f lx".format(stringResource(R.string.lux), luxValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
envMetrics.uvLux?.let { uvLuxValue ->
|
||||
if (!uvLuxValue.isNaN()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.0f UVlx".format(stringResource(R.string.uv_lux), uvLuxValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
envMetrics.voltage?.let { voltage ->
|
||||
if (!voltage.isNaN()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.2f V".format(stringResource(R.string.voltage), voltage),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envMetrics.current?.let { current ->
|
||||
if (!current.isNaN()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.2f A".format(stringResource(R.string.current), current),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GasResistanceDisplay(gasResistance: Float) {
|
||||
if (!gasResistance.isNaN()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.2f Ohm".format(stringResource(R.string.gas_resistance), gasResistance),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
Text(
|
||||
text = textFormat.format(stringResource(id = R.string.temperature), envMetrics.temperature),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
/* Humidity and Barometric Pressure */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text =
|
||||
"%s %.2f%%".format(stringResource(id = R.string.humidity), envMetrics.relativeHumidity),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
if (envMetrics.barometricPressure > 0) {
|
||||
Text(
|
||||
text = "%.2f hPa".format(envMetrics.barometricPressure),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* Soil Moisture and Soil Temperature */
|
||||
val soilMoistureRange = 0..100
|
||||
if (
|
||||
telemetry.environmentMetrics.hasSoilTemperature() ||
|
||||
telemetry.environmentMetrics.soilMoisture in soilMoistureRange
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val soilTemperatureTextFormat =
|
||||
if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
val soilMoistureTextFormat = "%s %d%%"
|
||||
Text(
|
||||
text =
|
||||
soilMoistureTextFormat.format(
|
||||
stringResource(R.string.soil_moisture),
|
||||
envMetrics.soilMoisture,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
soilTemperatureTextFormat.format(
|
||||
stringResource(R.string.soil_temperature),
|
||||
envMetrics.soilTemperature,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (telemetry.environmentMetrics.hasIaq()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
/* Air Quality */
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.iaq),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IndoorAirQuality(iaq = telemetry.environmentMetrics.iaq, displayMode = IaqDisplayMode.Dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
envMetrics.temperature?.let { temperature -> TemperatureDisplay(temperature, environmentDisplayFahrenheit) }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
/* Humidity and Barometric Pressure */
|
||||
HumidityAndBarometricPressureDisplay(envMetrics)
|
||||
|
||||
/* Soil Moisture and Soil Temperature */
|
||||
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)
|
||||
|
||||
envMetrics.iaq?.let { iaqValue -> IaqDisplay(iaqValue) }
|
||||
|
||||
LuxUVLuxDisplay(envMetrics)
|
||||
|
||||
VoltageCurrentDisplay(envMetrics)
|
||||
|
||||
envMetrics.gasResistance?.let { gasResistance -> GasResistanceDisplay(gasResistance) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,9 +184,9 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
|
|||
val minValue = 0f
|
||||
val legendData =
|
||||
listOf(
|
||||
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
|
||||
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
|
||||
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
|
||||
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
|
||||
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
|
||||
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
|
|||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true),
|
||||
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true),
|
||||
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
|
||||
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIG
|
|||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
|
||||
LegendData(nameRes = R.string.snr, color = Metric.SNR.color),
|
||||
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color, environmentMetric = null),
|
||||
LegendData(nameRes = R.string.snr, color = Metric.SNR.color, environmentMetric = null),
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue