From 10df4d47f1d962e7a6822f7a9cb80eedacabde4f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Feb 2026 23:01:42 -0600
Subject: [PATCH] feat(ui): Redesign NodeItem for improved clarity and density
(#4475)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 16 +-
.../core/ui/component/DistanceInfo.kt | 1 +
.../core/ui/component/ElevationInfo.kt | 1 +
.../meshtastic/core/ui/component/HopsInfo.kt | 1 +
.../meshtastic/core/ui/component/IconInfo.kt | 34 +-
.../core/ui/component/LastHeardInfo.kt | 2 +
.../core/ui/component/LoraSignalIndicator.kt | 12 +-
.../core/ui/component/MaterialBatteryInfo.kt | 33 +-
.../meshtastic/core/ui/component/NodeChip.kt | 2 +-
.../core/ui/component/SatelliteCountInfo.kt | 1 +
.../core/ui/component/SignalInfo.kt | 100 ++---
.../core/ui/component/TelemetryInfo.kt | 51 ++-
.../org/meshtastic/core/ui/icon/Telemetry.kt | 4 +
.../org/meshtastic/core/ui/util/FormatAgo.kt | 3 +
.../feature/node/component/DistanceInfo.kt | 4 +-
.../feature/node/component/ElevationInfo.kt | 1 +
.../feature/node/component/HopsInfo.kt | 1 +
.../feature/node/component/IconInfo.kt | 2 +
.../feature/node/component/LastHeardInfo.kt | 1 +
.../feature/node/component/NodeItem.kt | 369 ++++++++++++------
.../node/component/SatelliteCountInfo.kt | 4 +-
.../component/TelemetricActionsSection.kt | 4 +-
.../feature/node/component/TelemetryInfo.kt | 22 +-
23 files changed, 438 insertions(+), 231 deletions(-)
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 80b2e2928..38430f3f8 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -190,7 +190,7 @@
MSL
ChUtil %.1f%% AirUtilTX %.1f%%
- Channel
+ Ch
Channel Name
QR code
Unknown Username
@@ -392,12 +392,12 @@
Invalid WiFi Credential QR code format
Navigate Back
Battery
- Channel Utilization
- Air Utilization
- Temperature
- Humidity
- Soil Temperature
- Soil Moisture
+ ChUtil
+ AirUtil
+ Temp
+ Hum
+ Soil Temp
+ Soil Moist
Logs
Hops Away
Hops Away: %1$d
@@ -479,7 +479,7 @@
Low battery notifications
Low battery: %1$s
Low battery notifications (favorite nodes)
- Barometric Pressure
+ Baro
Enabled
UDP Broadcast
UDP Config
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DistanceInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DistanceInfo.kt
index 45cb45c9f..3ced8c7cc 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DistanceInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DistanceInfo.kt
@@ -38,6 +38,7 @@ fun DistanceInfo(
modifier = modifier,
icon = MeshtasticIcons.Distance,
contentDescription = stringResource(Res.string.distance),
+ label = stringResource(Res.string.distance),
text = distance,
contentColor = contentColor,
)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt
index bdbaa6043..176cd6c7d 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt
@@ -43,6 +43,7 @@ fun ElevationInfo(
modifier = modifier,
icon = MeshtasticIcons.Elevation,
contentDescription = stringResource(Res.string.altitude),
+ label = stringResource(Res.string.altitude),
text = altitude.metersIn(system).toString(system) + " " + suffix,
contentColor = contentColor,
)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt
index 92def6bb6..270ef1255 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/HopsInfo.kt
@@ -34,6 +34,7 @@ fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = Mat
modifier = modifier,
icon = MeshtasticIcons.Hops,
contentDescription = stringResource(Res.string.hops_away),
+ label = stringResource(Res.string.hops_away),
text = hops.toString(),
contentColor = contentColor,
)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IconInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IconInfo.kt
index 82887b8f6..61628468f 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IconInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IconInfo.kt
@@ -28,18 +28,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
-private const val SIZE_ICON = 20
+private const val SIZE_ICON = 14
@Composable
fun IconInfo(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
+ label: String? = null,
text: String? = null,
style: TextStyle = MaterialTheme.typography.labelMedium,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
@@ -54,9 +58,31 @@ fun IconInfo(
modifier = Modifier.size(SIZE_ICON.dp),
imageVector = icon,
contentDescription = contentDescription,
- tint = contentColor,
+ tint = contentColor.copy(alpha = 0.65f),
)
- text?.let { Text(text = it, style = style, color = contentColor) }
+ if (label != null || text != null) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ label?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp),
+ color = contentColor.copy(alpha = 0.55f),
+ maxLines = 1,
+ overflow = TextOverflow.Clip,
+ softWrap = false,
+ )
+ }
+ text?.let {
+ Text(
+ text = it,
+ style = style.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
+ color = contentColor.copy(alpha = 0.95f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
content()
}
}
@@ -65,6 +91,6 @@ fun IconInfo(
@Preview
private fun IconInfoPreview() {
MaterialTheme {
- IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", content = { Text(text = "100") })
+ IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", label = "Elevation", text = "100m")
}
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt
index b6e0a036d..c65214c0f 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt
@@ -34,12 +34,14 @@ import org.meshtastic.core.ui.util.formatAgo
fun LastHeardInfo(
modifier: Modifier = Modifier,
lastHeard: Int,
+ showLabel: Boolean = true,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
IconInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
contentDescription = stringResource(Res.string.node_sort_last_heard),
+ label = if (showLabel) stringResource(Res.string.node_sort_last_heard) else null,
text = formatAgo(lastHeard),
contentColor = contentColor,
)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index 13bd0ac90..b96eb81de 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -98,7 +98,7 @@ fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
maxLines = 1,
)
Icon(
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(SIZE_ICON_DP.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(Res.string.signal_quality),
tint = quality.color.invoke(),
@@ -106,6 +106,8 @@ fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
}
}
+private const val SIZE_ICON_DP = 16
+
/** Displays the `snr` and `rssi` with color depending on the values respectively. */
@Composable
fun SnrAndRssi(snr: Float, rssi: Int) {
@@ -125,7 +127,7 @@ fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialThe
modifier = Modifier.fillMaxSize().padding(8.dp),
) {
Icon(
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(SIZE_ICON_DP.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(Res.string.signal_quality),
tint = quality.color.invoke(),
@@ -139,7 +141,7 @@ fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialThe
}
@Composable
-fun Snr(snr: Float) {
+fun Snr(snr: Float, modifier: Modifier = Modifier) {
val color: Color =
if (snr > SNR_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
@@ -150,6 +152,7 @@ fun Snr(snr: Float) {
}
Text(
+ modifier = modifier,
text = "%s %.2fdB".format(stringResource(Res.string.snr), snr),
color = color,
style = MaterialTheme.typography.labelSmall,
@@ -157,7 +160,7 @@ fun Snr(snr: Float) {
}
@Composable
-fun Rssi(rssi: Int) {
+fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
val color: Color =
if (rssi > RSSI_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
@@ -167,6 +170,7 @@ fun Rssi(rssi: Int) {
Quality.BAD.color.invoke()
}
Text(
+ modifier = modifier,
text = "%s %ddBm".format(stringResource(Res.string.rssi), rssi),
color = color,
style = MaterialTheme.typography.labelSmall,
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
index a854114fe..df8cc989f 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
@@ -33,10 +32,12 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown
@@ -49,7 +50,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
private const val FORMAT = "%d%%"
-private const val SIZE_ICON = 20
+private const val SIZE_ICON = 16
@Suppress("MagicNumber", "LongMethod")
@Composable
@@ -64,24 +65,28 @@ fun MaterialBatteryInfo(
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
if (level == null || level < 0) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp),
imageVector = MeshtasticIcons.BatteryUnknown,
- tint = contentColor,
+ tint = contentColor.copy(alpha = 0.65f),
contentDescription = stringResource(Res.string.unknown),
)
} else if (level > 100) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp).rotate(90f),
imageVector = Icons.Rounded.Power,
- tint = contentColor,
+ tint = contentColor.copy(alpha = 0.65f),
contentDescription = levelString,
)
- Text(text = "PWD", color = contentColor, style = MaterialTheme.typography.labelMedium)
+ Text(
+ text = "PWR",
+ color = contentColor.copy(alpha = 0.95f),
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
+ )
} else {
// Map battery percentage to color
val fillColor =
@@ -111,16 +116,24 @@ fun MaterialBatteryInfo(
)
},
imageVector = MeshtasticIcons.BatteryEmpty,
- tint = contentColor,
+ tint = contentColor.copy(alpha = 0.65f),
contentDescription = levelString,
)
- Text(text = levelString, color = contentColor, style = MaterialTheme.typography.labelMedium)
+ Text(
+ text = levelString,
+ color = contentColor.copy(alpha = 0.95f),
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
+ )
}
voltage
?.takeIf { it > 0 }
?.let {
- Text(text = "%.2fV".format(it), color = contentColor, style = MaterialTheme.typography.labelMedium)
+ Text(
+ text = "%.2fV".format(it),
+ color = contentColor.copy(alpha = 0.8f),
+ style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
+ )
}
}
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt
index 86014ec6b..b1df96dcc 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt
@@ -50,7 +50,7 @@ fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit
Box(
modifier =
Modifier.width(IntrinsicSize.Min)
- .defaultMinSize(minWidth = 72.dp, minHeight = 32.dp)
+ .defaultMinSize(minWidth = 64.dp, minHeight = 28.dp)
.padding(horizontal = 8.dp)
.semantics { contentDescription = node.user.short_name.ifEmpty { "Node" } },
contentAlignment = Alignment.Center,
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SatelliteCountInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SatelliteCountInfo.kt
index 635d7524a..2474c500f 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SatelliteCountInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SatelliteCountInfo.kt
@@ -38,6 +38,7 @@ fun SatelliteCountInfo(
modifier = modifier,
icon = MeshtasticIcons.Satellites,
contentDescription = stringResource(Res.string.sats),
+ label = stringResource(Res.string.sats),
text = "$satCount",
contentColor = contentColor,
)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
index 21efe1bd5..b698f5b10 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
@@ -18,83 +18,62 @@ package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.air_utilization
-import org.meshtastic.core.strings.channel_utilization
-import org.meshtastic.core.strings.signal
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
-import org.meshtastic.core.ui.icon.AirUtilization
-import org.meshtastic.core.ui.icon.ChannelUtilization
-import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
-@Suppress("LongMethod")
@Composable
fun SignalInfo(
modifier: Modifier = Modifier,
node: Node,
- isThisNode: Boolean,
- contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ @Suppress("UNUSED_PARAMETER") contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (isThisNode) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- IconInfo(
- icon = MeshtasticIcons.ChannelUtilization,
- contentDescription = stringResource(Res.string.channel_utilization),
- text = "%.1f%%".format(node.deviceMetrics.channel_utilization),
- contentColor = contentColor,
- )
- IconInfo(
- icon = MeshtasticIcons.AirUtilization,
- contentDescription = stringResource(Res.string.air_utilization),
- text = "%.1f%%".format(node.deviceMetrics.air_util_tx),
- contentColor = contentColor,
- )
- }
- } else {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- if (node.channel > 0) {
- ChannelInfo(channel = node.channel, contentColor = contentColor)
- }
- if (node.hopsAway > 0) {
- HopsInfo(hops = node.hopsAway, contentColor = contentColor)
- } else {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
- val quality = determineSignalQuality(node.snr, node.rssi)
- Snr(node.snr)
- Rssi(node.rssi)
- IconInfo(
- icon = quality.imageVector,
- contentDescription = stringResource(Res.string.signal_quality),
- contentColor = quality.color.invoke(),
- text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
- )
- }
- }
- }
- }
+ if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
+ val quality = determineSignalQuality(node.snr, node.rssi)
+ val signalColor = quality.color.invoke()
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ imageVector = quality.imageVector,
+ contentDescription = stringResource(Res.string.signal_quality),
+ modifier = Modifier.size(16.dp),
+ tint = signalColor,
+ )
+ Text(
+ text = "%.1fdB · %ddBm · %s".format(node.snr, node.rssi, stringResource(quality.nameRes)),
+ style =
+ MaterialTheme.typography.labelSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 10.sp,
+ letterSpacing = 0.sp,
+ ),
+ color = signalColor,
+ maxLines = 1,
+ softWrap = false,
+ )
}
}
}
@@ -102,22 +81,11 @@ fun SignalInfo(
@Composable
@Preview(showBackground = true)
fun SignalInfoSimplePreview() {
- AppTheme {
- SignalInfo(
- node = Node(num = 1, lastHeard = 0, channel = 0, snr = 12.5F, rssi = -42, hopsAway = 0),
- isThisNode = false,
- )
- }
+ AppTheme { SignalInfo(node = Node(num = 1, lastHeard = 0, channel = 0, snr = 12.5F, rssi = -42, hopsAway = 0)) }
}
@PreviewLightDark
@Composable
fun SignalInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
- AppTheme { SignalInfo(node = node, isThisNode = false) }
-}
-
-@Composable
-@PreviewLightDark
-fun SignalInfoSelfPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
- AppTheme { SignalInfo(node = node, isThisNode = true) }
+ AppTheme { SignalInfo(node = node) }
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt
index 32d4e3afd..dfca24d24 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt
@@ -34,13 +34,23 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.baro_pressure
import org.meshtastic.core.strings.env_metrics_log
+import org.meshtastic.core.strings.humidity
+import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.node_id
+import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.role
+import org.meshtastic.core.strings.soil_moisture
+import org.meshtastic.core.strings.soil_temperature
+import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.ArrowCircleUp
@@ -55,7 +65,7 @@ import org.meshtastic.core.ui.icon.Role
import org.meshtastic.core.ui.icon.Soil
import org.meshtastic.core.ui.icon.Temperature
-private const val SIZE_ICON = 20
+private const val SIZE_ICON = 14
@Composable
fun TemperatureInfo(
@@ -67,6 +77,7 @@ fun TemperatureInfo(
modifier = modifier,
icon = MeshtasticIcons.Temperature,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.temperature),
text = temp,
contentColor = contentColor,
)
@@ -82,6 +93,7 @@ fun HumidityInfo(
modifier = modifier,
icon = MeshtasticIcons.Humidity,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.humidity),
text = humidity,
contentColor = contentColor,
)
@@ -97,6 +109,7 @@ fun PressureInfo(
modifier = modifier,
icon = MeshtasticIcons.Pressure,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.baro_pressure),
text = pressure,
contentColor = contentColor,
)
@@ -113,6 +126,7 @@ fun SoilTemperatureInfo(
icon = MeshtasticIcons.Soil,
overlayIcon = MeshtasticIcons.Temperature,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.soil_temperature),
text = temp,
contentColor = contentColor,
)
@@ -129,6 +143,7 @@ fun SoilMoistureInfo(
icon = MeshtasticIcons.Soil,
overlayIcon = MeshtasticIcons.Humidity,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.soil_moisture),
text = moisture,
contentColor = contentColor,
)
@@ -144,6 +159,7 @@ fun PaxcountInfo(
modifier = modifier,
icon = MeshtasticIcons.Paxcount,
contentDescription = stringResource(Res.string.pax_metrics_log),
+ label = stringResource(Res.string.pax),
text = pax,
contentColor = contentColor,
)
@@ -159,17 +175,24 @@ fun AirQualityInfo(
modifier = modifier,
icon = MeshtasticIcons.AirQuality,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.iaq),
text = iaq,
contentColor = contentColor,
)
}
@Composable
-fun PowerInfo(value: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) {
+fun PowerInfo(
+ value: String,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
IconInfo(
modifier = modifier,
icon = MeshtasticIcons.Power,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = label,
text = value,
contentColor = contentColor,
)
@@ -185,6 +208,7 @@ fun UptimeInfo(
modifier = modifier,
icon = MeshtasticIcons.ArrowCircleUp,
contentDescription = stringResource(Res.string.uptime),
+ label = stringResource(Res.string.uptime),
text = uptime,
contentColor = contentColor,
)
@@ -237,6 +261,7 @@ fun OverlayIconInfo(
overlayIcon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
+ label: String? = null,
text: String? = null,
style: TextStyle = MaterialTheme.typography.labelMedium,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
@@ -250,7 +275,7 @@ fun OverlayIconInfo(
Icon(
imageVector = icon,
contentDescription = contentDescription,
- tint = contentColor,
+ tint = contentColor.copy(alpha = 0.65f),
modifier =
Modifier.size(SIZE_ICON.dp).drawWithContent {
drawContent()
@@ -260,6 +285,24 @@ fun OverlayIconInfo(
}
},
)
- text?.let { Text(text = it, style = style, color = contentColor) }
+ label?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp),
+ color = contentColor.copy(alpha = 0.55f),
+ maxLines = 1,
+ overflow = TextOverflow.Clip,
+ softWrap = false,
+ )
+ }
+ text?.let {
+ Text(
+ text = it,
+ style = style.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
+ color = contentColor.copy(alpha = 0.95f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt
index ce52f0085..56f51bd8a 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.Air
import androidx.compose.material.icons.rounded.DataArray
import androidx.compose.material.icons.rounded.ElectricBolt
import androidx.compose.material.icons.rounded.Grass
+import androidx.compose.material.icons.rounded.LineAxis
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.SocialDistance
import androidx.compose.material.icons.rounded.Speed
@@ -54,3 +55,6 @@ val MeshtasticIcons.Speed: ImageVector
get() = Icons.Rounded.Speed
val MeshtasticIcons.Chart: ImageVector
get() = Icons.Rounded.StackedLineChart
+
+val MeshtasticIcons.LineAxis: ImageVector
+ get() = Icons.Rounded.LineAxis
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
index 709a32590..ba08ea36e 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
@@ -20,6 +20,7 @@ import android.text.format.DateUtils
import com.meshtastic.core.strings.getString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.now
+import org.meshtastic.core.strings.unknown
import java.lang.System.currentTimeMillis
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@@ -36,6 +37,8 @@ import kotlin.time.Duration.Companion.seconds
* @return A [String] representing the relative time that has passed.
*/
fun formatAgo(lastSeenUnixSeconds: Int): String {
+ if (lastSeenUnixSeconds <= 0) return getString(Res.string.unknown)
+
val lastSeenDuration = lastSeenUnixSeconds.seconds
val currentDuration = currentTimeMillis().milliseconds
val diff = (currentDuration - lastSeenDuration).absoluteValue
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt
index f378dab41..4d13c59bf 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
@@ -39,6 +38,7 @@ fun DistanceInfo(
modifier = modifier,
icon = Icons.Rounded.SocialDistance,
contentDescription = stringResource(Res.string.distance),
+ label = stringResource(Res.string.distance),
text = distance,
contentColor = contentColor,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt
index 3736a6f2d..6a81858ef 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt
@@ -43,6 +43,7 @@ fun ElevationInfo(
modifier = modifier,
icon = MeshtasticIcons.Elevation,
contentDescription = stringResource(Res.string.altitude),
+ label = stringResource(Res.string.altitude),
text = altitude.metersIn(system).toString(system) + " " + suffix,
contentColor = contentColor,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt
index 3a339f51b..2226621ef 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt
@@ -34,6 +34,7 @@ fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = Mat
modifier = modifier,
icon = Icons.Rounded.CrueltyFree,
contentDescription = stringResource(Res.string.hops_away),
+ label = stringResource(Res.string.hops_away),
text = hops.toString(),
contentColor = contentColor,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/IconInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/IconInfo.kt
index 60812deb8..1c02eb024 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/IconInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/IconInfo.kt
@@ -40,6 +40,7 @@ fun IconInfo(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
+ label: String? = null,
text: String? = null,
style: TextStyle = MaterialTheme.typography.labelMedium,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
@@ -56,6 +57,7 @@ fun IconInfo(
contentDescription = contentDescription,
tint = contentColor,
)
+ label?.let { Text(text = it, style = style, color = contentColor) }
text?.let { Text(text = it, style = style, color = contentColor) }
content()
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt
index c79a2ab79..cc55ed1d3 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt
@@ -40,6 +40,7 @@ fun LastHeardInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
contentDescription = stringResource(Res.string.node_sort_last_heard),
+ label = stringResource(Res.string.node_sort_last_heard),
text = formatAgo(lastHeard),
contentColor = contentColor,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index c90dc22c6..3e7c65fcc 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -14,11 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("MagicNumber")
+
package org.meshtastic.feature.node.component
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@@ -40,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -50,13 +54,21 @@ import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.air_utilization
+import org.meshtastic.core.strings.channel_utilization
+import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.elevation_suffix
+import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.unknown_username
+import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.AirQualityInfo
+import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo
import org.meshtastic.core.ui.component.ElevationInfo
import org.meshtastic.core.ui.component.HardwareInfo
+import org.meshtastic.core.ui.component.HopsInfo
import org.meshtastic.core.ui.component.HumidityInfo
+import org.meshtastic.core.ui.component.IconInfo
import org.meshtastic.core.ui.component.LastHeardInfo
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.NodeChip
@@ -66,21 +78,27 @@ import org.meshtastic.core.ui.component.PaxcountInfo
import org.meshtastic.core.ui.component.PowerInfo
import org.meshtastic.core.ui.component.PressureInfo
import org.meshtastic.core.ui.component.RoleInfo
+import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.SatelliteCountInfo
-import org.meshtastic.core.ui.component.SignalInfo
+import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.SoilMoistureInfo
import org.meshtastic.core.ui.component.SoilTemperatureInfo
import org.meshtastic.core.ui.component.TemperatureInfo
+import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import org.meshtastic.core.ui.icon.AirUtilization
+import org.meshtastic.core.ui.icon.ChannelUtilization
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Config
private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
+private const val GRID_COLUMNS = 3
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
+@Suppress("LongMethod")
fun NodeItem(
thisNode: Node?,
thatNode: Node,
@@ -97,15 +115,6 @@ fun NodeItem(
val isIgnored = thatNode.isIgnored
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
- @Suppress("MagicNumber")
- val longName =
- remember(originalLongName) {
- if (originalLongName.length > 20) {
- "${originalLongName.take(20)}…"
- } else {
- originalLongName
- }
- }
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system =
remember(distanceUnits) {
@@ -146,13 +155,13 @@ fun NodeItem(
Card(modifier = modifier.fillMaxWidth(), colors = cardColors) {
Column(
modifier =
- Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(8.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
+ Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
) {
NodeItemHeader(
thatNode = thatNode,
isThisNode = isThisNode,
- longName = longName,
+ longName = originalLongName,
style = style,
isIgnored = isIgnored,
isFavorite = isFavorite,
@@ -162,17 +171,201 @@ fun NodeItem(
contentColor = contentColor,
)
- NodeItemMetrics(thatNode = thatNode, distance = distance, system = system, contentColor = contentColor)
+ NodeBatteryPositionRow(
+ thatNode = thatNode,
+ distance = distance,
+ system = system,
+ contentColor = contentColor,
+ )
- SignalInfo(node = thatNode, isThisNode = isThisNode, contentColor = contentColor)
+ NodeSignalRow(thatNode = thatNode, isThisNode = isThisNode, contentColor = contentColor)
- NodeItemEnvironment(thatNode = thatNode, tempInFahrenheit = tempInFahrenheit, contentColor = contentColor)
+ val sensorItems = gatherSensors(thatNode, tempInFahrenheit, contentColor)
+ if (sensorItems.isNotEmpty()) {
+ MetricsGrid(sensorItems)
+ }
NodeItemFooter(thatNode = thatNode, contentColor = contentColor)
}
}
}
+@Composable
+private fun NodeBatteryPositionRow(
+ thatNode: Node,
+ distance: String?,
+ system: Config.DisplayConfig.DisplayUnits,
+ contentColor: Color,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ MaterialBatteryInfo(
+ level = thatNode.batteryLevel ?: 0,
+ voltage = thatNode.voltage ?: 0f,
+ contentColor = contentColor,
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (distance != null) {
+ DistanceInfo(distance = distance, contentColor = contentColor)
+ }
+ thatNode.validPosition?.let { position ->
+ ElevationInfo(
+ altitude = position.altitude ?: 0,
+ system = system,
+ suffix = stringResource(Res.string.elevation_suffix),
+ contentColor = contentColor,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Color) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isThisNode) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ IconInfo(
+ icon = MeshtasticIcons.ChannelUtilization,
+ contentDescription = stringResource(Res.string.channel_utilization),
+ label = stringResource(Res.string.channel_utilization),
+ text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization),
+ contentColor = contentColor,
+ )
+ IconInfo(
+ icon = MeshtasticIcons.AirUtilization,
+ contentDescription = stringResource(Res.string.air_utilization),
+ label = stringResource(Res.string.air_utilization),
+ text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx),
+ contentColor = contentColor,
+ )
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (thatNode.hopsAway > 0) {
+ HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor)
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (thatNode.snr < 100f) Snr(thatNode.snr)
+ if (thatNode.rssi < 0) Rssi(thatNode.rssi)
+ if (thatNode.snr < 100f && thatNode.rssi < 0) {
+ val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
+ IconInfo(
+ icon = quality.imageVector,
+ contentDescription = stringResource(Res.string.signal_quality),
+ contentColor = quality.color.invoke(),
+ text = stringResource(quality.nameRes),
+ )
+ }
+ }
+ }
+ if (thatNode.channel > 0) {
+ ChannelInfo(channel = thatNode.channel, contentColor = contentColor)
+ }
+ }
+ }
+
+ val satCount = thatNode.validPosition?.sats_in_view ?: 0
+ if (satCount > 0) {
+ SatelliteCountInfo(satCount = satCount, contentColor = contentColor)
+ } else {
+ Spacer(Modifier)
+ }
+ }
+}
+
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: Color): List<@Composable () -> Unit> {
+ val items = mutableListOf<@Composable () -> Unit>()
+ val env = node.environmentMetrics
+ val pax = node.paxcounter
+
+ if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
+ items.add { PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor) }
+ }
+ if ((env.temperature ?: 0f) != 0f) {
+ val temp =
+ if (tempInFahrenheit) {
+ "%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
+ } else {
+ "%.1f°C".format(env.temperature ?: 0f)
+ }
+ items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
+ }
+ if ((env.relative_humidity ?: 0f) != 0f) {
+ items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) }
+ }
+ if ((env.barometric_pressure ?: 0f) != 0f) {
+ items.add {
+ PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
+ }
+ }
+ if ((env.soil_temperature ?: 0f) != 0f) {
+ val temp =
+ if (tempInFahrenheit) {
+ "%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
+ } else {
+ "%.1f°C".format(env.soil_temperature ?: 0f)
+ }
+ items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
+ }
+ if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
+ items.add { SoilMoistureInfo(moisture = "${env.soil_moisture}%", contentColor = contentColor) }
+ }
+ if ((env.voltage ?: 0f) != 0f) {
+ items.add {
+ PowerInfo(
+ value = "%.2fV".format(env.voltage ?: 0f),
+ label = stringResource(Res.string.voltage),
+ contentColor = contentColor,
+ )
+ }
+ }
+ if ((env.current ?: 0f) != 0f) {
+ items.add {
+ PowerInfo(
+ value = "%.1fmA".format(env.current ?: 0f),
+ label = stringResource(Res.string.current),
+ contentColor = contentColor,
+ )
+ }
+ }
+ if ((env.iaq ?: 0) != 0) {
+ items.add { AirQualityInfo(iaq = "${env.iaq}", contentColor = contentColor) }
+ }
+
+ return items
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun MetricsGrid(items: List<@Composable () -> Unit>) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ maxItemsInEachRow = GRID_COLUMNS,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ val remainder = items.size % GRID_COLUMNS
+ items.forEach { item -> Box(Modifier.weight(1f)) { item() } }
+ if (remainder != 0) {
+ repeat(GRID_COLUMNS - remainder) { Spacer(Modifier.weight(1f)) }
+ }
+ }
+}
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun NodeItemHeader(
@@ -187,23 +380,31 @@ private fun NodeItemHeader(
connectionState: ConnectionState,
contentColor: Color,
) {
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
NodeChip(node = thatNode)
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
mismatchKey = thatNode.mismatchKey,
publicKey = thatNode.user.public_key,
- modifier = Modifier.size(32.dp),
+ modifier = Modifier.size(24.dp),
)
- Text(
- modifier = Modifier.weight(1f),
- text = longName,
- style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
- textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
- softWrap = true,
- )
- LastHeardInfo(lastHeard = thatNode.lastHeard, contentColor = contentColor)
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = longName,
+ style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
+ textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
+ }
+
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
@@ -215,102 +416,6 @@ private fun NodeItemHeader(
}
}
-@Composable
-private fun NodeItemMetrics(
- thatNode: Node,
- distance: String?,
- system: Config.DisplayConfig.DisplayUnits,
- contentColor: Color,
-) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- if ((thatNode.batteryLevel ?: 0) > 0 || (thatNode.voltage ?: 0f) > 0f) {
- MaterialBatteryInfo(
- level = thatNode.batteryLevel ?: 0,
- voltage = thatNode.voltage ?: 0f,
- contentColor = contentColor,
- )
- } else {
- Spacer(modifier = Modifier.weight(1f))
- }
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- if (distance != null) {
- DistanceInfo(distance = distance, contentColor = contentColor)
- }
- thatNode.validPosition?.let { position ->
- ElevationInfo(
- altitude = position.altitude ?: 0,
- system = system,
- suffix = stringResource(Res.string.elevation_suffix),
- contentColor = contentColor,
- )
- }
- val satCount = thatNode.validPosition?.sats_in_view ?: 0
- if (satCount > 0) {
- SatelliteCountInfo(satCount = satCount, contentColor = contentColor)
- }
- }
- }
-}
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-@Suppress("CyclomaticComplexMethod")
-private fun NodeItemEnvironment(thatNode: Node, tempInFahrenheit: Boolean, contentColor: Color) {
- val env = thatNode.environmentMetrics
- val pax = thatNode.paxcounter
- if (thatNode.hasEnvironmentMetrics || (pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
- PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor)
- }
- if ((env.temperature ?: 0f) != 0f) {
- val temp =
- if (tempInFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
- } else {
- "%.1f°C".format(env.temperature ?: 0f)
- }
- TemperatureInfo(temp = temp, contentColor = contentColor)
- }
- if ((env.relative_humidity ?: 0f) != 0f) {
- HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor)
- }
- if ((env.barometric_pressure ?: 0f) != 0f) {
- PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
- }
- if ((env.soil_temperature ?: 0f) != 0f) {
- val temp =
- if (tempInFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
- } else {
- "%.1f°C".format(env.soil_temperature ?: 0f)
- }
- SoilTemperatureInfo(temp = temp, contentColor = contentColor)
- }
- if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
- SoilMoistureInfo(moisture = "${env.soil_moisture}%", contentColor = contentColor)
- }
- if ((env.voltage ?: 0f) != 0f) {
- PowerInfo(value = "%.2fV".format(env.voltage ?: 0f), contentColor = contentColor)
- }
- if ((env.current ?: 0f) != 0f) {
- PowerInfo(value = "%.1fmA".format(env.current ?: 0f), contentColor = contentColor)
- }
- if ((env.iaq ?: 0) != 0) {
- AirQualityInfo(iaq = "${env.iaq}", contentColor = contentColor)
- }
- }
- }
-}
-
@Composable
private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
Row(
@@ -318,9 +423,9 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- HardwareInfo(hwModel = thatNode.user.hw_model?.name ?: "", contentColor = contentColor)
- RoleInfo(role = thatNode.user.role?.name ?: "", contentColor = contentColor)
- NodeIdInfo(id = (thatNode.user.id ?: "").ifEmpty { "???" }, contentColor = contentColor)
+ HardwareInfo(hwModel = thatNode.user.hw_model.name, contentColor = contentColor)
+ RoleInfo(role = thatNode.user.role.name, contentColor = contentColor)
+ NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
}
}
@@ -329,7 +434,17 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
- val thatNode = NodePreviewParameterProvider().values.last()
+ val thatNode = NodePreviewParameterProvider().values.last().copy(lastHeard = 0)
+ NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
+ }
+}
+
+@Composable
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+fun NodeInfoSignalPreview() {
+ AppTheme {
+ val thisNode = NodePreviewParameterProvider().values.first()
+ val thatNode = NodePreviewParameterProvider().values.last().copy(hopsAway = 0, snr = 5.5f, rssi = -100)
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt
index 8f3298029..da1be0f44 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/SatelliteCountInfo.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
@@ -39,6 +38,7 @@ fun SatelliteCountInfo(
modifier = modifier,
icon = Icons.TwoTone.SatelliteAlt,
contentDescription = stringResource(Res.string.sats),
+ label = stringResource(Res.string.sats),
text = "$satCount",
contentColor = contentColor,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
index 38bd41eed..d67141e6e 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
@@ -56,7 +56,7 @@ import org.meshtastic.core.strings.request_telemetry
import org.meshtastic.core.strings.telemetry
import org.meshtastic.core.strings.userinfo
import org.meshtastic.core.ui.icon.AirQuality
-import org.meshtastic.core.ui.icon.Chart
+import org.meshtastic.core.ui.icon.LineAxis
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Refresh
@@ -232,7 +232,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
},
) {
Icon(
- MeshtasticIcons.Chart,
+ MeshtasticIcons.LineAxis,
contentDescription = logsDescription,
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
tint = MaterialTheme.colorScheme.primary,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt
index 5875bbaad..6fa47e7e1 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt
@@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("TooManyFunctions")
+
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
@@ -33,9 +35,15 @@ import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.env_metrics_log
+import org.meshtastic.core.strings.humidity
+import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.node_id
+import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.role
+import org.meshtastic.core.strings.soil_moisture
+import org.meshtastic.core.strings.soil_temperature
+import org.meshtastic.core.strings.temperature
@Composable
fun TemperatureInfo(
@@ -47,6 +55,7 @@ fun TemperatureInfo(
modifier = modifier,
icon = Icons.Rounded.Thermostat,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.temperature),
text = temp,
contentColor = contentColor,
)
@@ -62,6 +71,7 @@ fun HumidityInfo(
modifier = modifier,
icon = Icons.Rounded.WaterDrop,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.humidity),
text = humidity,
contentColor = contentColor,
)
@@ -77,6 +87,7 @@ fun SoilTemperatureInfo(
modifier = modifier,
icon = Icons.Rounded.Grass,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.soil_temperature),
text = temp,
contentColor = contentColor,
)
@@ -92,6 +103,7 @@ fun SoilMoistureInfo(
modifier = modifier,
icon = Icons.Rounded.Grass,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.soil_moisture),
text = moisture,
contentColor = contentColor,
)
@@ -107,6 +119,7 @@ fun PaxcountInfo(
modifier = modifier,
icon = Icons.Rounded.People,
contentDescription = stringResource(Res.string.pax_metrics_log),
+ label = stringResource(Res.string.pax),
text = pax,
contentColor = contentColor,
)
@@ -122,17 +135,24 @@ fun AirQualityInfo(
modifier = modifier,
icon = Icons.Rounded.Air,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = stringResource(Res.string.iaq),
text = iaq,
contentColor = contentColor,
)
}
@Composable
-fun PowerInfo(value: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) {
+fun PowerInfo(
+ value: String,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
IconInfo(
modifier = modifier,
icon = Icons.Rounded.ElectricBolt,
contentDescription = stringResource(Res.string.env_metrics_log),
+ label = label,
text = value,
contentColor = contentColor,
)