feat(ui): Redesign NodeItem for improved clarity and density (#4475)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 23:01:42 -06:00 committed by GitHub
parent 96551761c8
commit 10df4d47f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 438 additions and 231 deletions

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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")
}
}

View file

@ -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,
)

View file

@ -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,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}
}
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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) }
}

View file

@ -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,
)
}
}
}

View file

@ -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

View file

@ -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