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