refactor: Move common UI components to core:ui and add screenshot tests

Relocate messaging and node-related UI components from feature modules to the shared core:ui module to improve reusability across the project. Enable the screenshot testing plugin and add comprehensive baseline tests for shared components.

- **feature/messaging**: Move `MessageItem`, `MessageActions`, `MessageBubble`, and `Reaction` components to core:ui.
- **feature/node**: Move `NodeItem`, `NodeStatusIcons`, `InfoCard`, and `CooldownIconButton` to core:ui.
- **core/ui**: Add new screenshot tests for `MainAppBar`, `NodeItem`, `MessageItem`, and various common UI components.
- **build.gradle.kts**: Configure the screenshot testing plugin and dependencies for node and messaging feature modules.
- Update imports and references across the codebase to reflect relocated components.
This commit is contained in:
James Rich 2026-03-02 10:32:55 -06:00
parent a19c463b37
commit 85847e1144
50 changed files with 332 additions and 138 deletions

View file

@ -21,6 +21,7 @@ plugins {
alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.android.library.compose)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.screenshot)
}
configure<LibraryExtension> {
@ -29,6 +30,8 @@ configure<LibraryExtension> {
defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = "DEBUG_KEY" }
testOptions { unitTests { isIncludeAndroidResources = true } }
experimentalProperties["android.experimental.enableScreenshotTest"] = true
}
dependencies {
@ -70,4 +73,10 @@ dependencies {
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.robolectric)
debugImplementation(libs.androidx.compose.ui.test.manifest)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
screenshotTestImplementation(libs.compose.multiplatform.runtime)
screenshotTestImplementation(libs.compose.multiplatform.resources)
screenshotTestImplementation(projects.core.resources)
}

View file

@ -1,142 +0,0 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
internal const val COOL_DOWN_TIME_MS = 30000L
internal const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes
@Composable
fun CooldownIconButton(
onClick: () -> Unit,
cooldownTimestamp: Long?,
cooldownDuration: Long = COOL_DOWN_TIME_MS,
content: @Composable () -> Unit,
) {
val progress = remember { Animatable(0f) }
LaunchedEffect(cooldownTimestamp) {
if (cooldownTimestamp == null) {
progress.snapTo(0f)
return@LaunchedEffect
}
val timeSinceLast = nowMillis - cooldownTimestamp
if (timeSinceLast < cooldownDuration) {
val remainingTime = cooldownDuration - timeSinceLast
progress.snapTo(remainingTime / cooldownDuration.toFloat())
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
)
} else {
progress.snapTo(0f)
}
}
val isCoolingDown = progress.value > 0f
IconButton(
onClick = { if (!isCoolingDown) onClick() },
enabled = !isCoolingDown,
colors = IconButtonDefaults.iconButtonColors(),
) {
if (isCoolingDown) {
CircularProgressIndicator(
progress = { progress.value },
modifier = Modifier.size(24.dp),
strokeCap = StrokeCap.Round,
)
} else {
content()
}
}
}
@Composable
fun CooldownOutlinedIconButton(
onClick: () -> Unit,
cooldownTimestamp: Long?,
cooldownDuration: Long = COOL_DOWN_TIME_MS,
content: @Composable () -> Unit,
) {
val progress = remember { Animatable(0f) }
LaunchedEffect(cooldownTimestamp) {
if (cooldownTimestamp == null) {
progress.snapTo(0f)
return@LaunchedEffect
}
val timeSinceLast = nowMillis - cooldownTimestamp
if (timeSinceLast < cooldownDuration) {
val remainingTime = cooldownDuration - timeSinceLast
progress.snapTo(remainingTime / cooldownDuration.toFloat())
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
)
} else {
progress.snapTo(0f)
}
}
val isCoolingDown = progress.value > 0f
OutlinedIconButton(
onClick = { if (!isCoolingDown) onClick() },
enabled = !isCoolingDown,
colors = IconButtonDefaults.outlinedIconButtonColors(),
) {
if (isCoolingDown) {
CircularProgressIndicator(
progress = { progress.value },
modifier = Modifier.size(24.dp),
strokeCap = StrokeCap.Round,
)
} else {
content()
}
}
}
@Preview(showBackground = true)
@Composable
private fun CooldownOutlinedIconButtonPreview() {
AppTheme {
CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = nowMillis - 15000L) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
}

View file

@ -61,6 +61,8 @@ import org.meshtastic.core.resources.uv_lux
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.resources.weight
import org.meshtastic.core.resources.wind
import org.meshtastic.core.ui.component.DrawableInfoCard
import org.meshtastic.core.ui.component.InfoCard
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
import org.meshtastic.proto.Config

View file

@ -1,125 +0,0 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.thenIf
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
fun InfoCard(
text: String,
value: String,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
iconRes: DrawableResource? = null,
rotateIcon: Float = 0f,
) {
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val shape = MaterialTheme.shapes.medium
val copyLabel = stringResource(Res.string.copy)
Card(
modifier =
modifier
.defaultMinSize(minHeight = 48.dp)
.clip(shape)
.combinedClickable(
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
},
onLongClickLabel = copyLabel,
onClick = {},
role = Role.Button,
)
.semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
shape = shape,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Row(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier.size(20.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }
val iconTint = MaterialTheme.colorScheme.primary
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = iconModifier, tint = iconTint)
}
if (iconRes != null) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
modifier = iconModifier,
tint = iconTint,
)
}
Column {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
value,
style = MaterialTheme.typography.labelLargeEmphasized,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@Composable
internal fun DrawableInfoCard(iconRes: DrawableResource, text: String, value: String, rotateIcon: Float = 0f) {
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
}

View file

@ -1,93 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Wind Dir -359°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionn359() {
PreviewWindDirectionItem(-359f)
}
@Preview(name = "Wind Dir 0°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection0() {
PreviewWindDirectionItem(0f)
}
@Preview(name = "Wind Dir 45°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection45() {
PreviewWindDirectionItem(45f)
}
@Preview(name = "Wind Dir 90°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection90() {
PreviewWindDirectionItem(90f)
}
@Preview(name = "Wind Dir 180°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection180() {
PreviewWindDirectionItem(180f)
}
@Preview(name = "Wind Dir 225°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection225() {
PreviewWindDirectionItem(225f)
}
@Preview(name = "Wind Dir 270°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection270() {
PreviewWindDirectionItem(270f)
}
@Preview(name = "Wind Dir 315°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection315() {
PreviewWindDirectionItem(315f)
}
@Preview(name = "Wind Dir -45")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionN45() {
PreviewWindDirectionItem(-45f)
}
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionItem(windDirection: Float, windSpeed: String = "5 m/s") {
val normalizedBearing = (windDirection + 180) % 360
InfoCard(icon = Icons.Outlined.Navigation, text = "Wind", value = windSpeed, rotateIcon = normalizedBearing)
}

View file

@ -1,510 +0,0 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.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
import org.meshtastic.core.ui.component.NodeIdInfo
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
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.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.TransportIcon
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)
@Composable
@Suppress("LongMethod")
fun NodeItem(
thisNode: Node?,
thatNode: Node,
distanceUnits: Int,
tempInFahrenheit: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
connectionState: ConnectionState,
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system =
remember(distanceUnits) {
Config.DisplayConfig.DisplayUnits.fromValue(distanceUnits) ?: Config.DisplayConfig.DisplayUnits.METRIC
}
val distance =
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
var contentColor = MaterialTheme.colorScheme.onSurface
val cardColors =
if (isThisNode) {
thisNode?.colors?.second
} else {
thatNode.colors.second
}
?.let {
val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
val containerColor = Color(it).copy(alpha = alpha)
contentColor = contentColorFor(containerColor)
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())
val style =
if (thatNode.isUnknownUser) {
FontStyle.Italic
} else {
FontStyle.Normal
}
val unmessageable =
remember(thatNode) {
when {
thatNode.user.is_unmessagable != null -> thatNode.user.is_unmessagable!!
else -> thatNode.user.role.isUnmessageableRole()
}
}
Card(modifier = modifier.fillMaxWidth(), colors = cardColors) {
Column(
modifier =
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
NodeItemHeader(
thatNode = thatNode,
isThisNode = isThisNode,
longName = originalLongName,
style = style,
isIgnored = isIgnored,
isFavorite = isFavorite,
isMuted = isMuted,
isUnmessageable = unmessageable,
connectionState = connectionState,
contentColor = contentColor,
)
thatNode.nodeStatus?.let { status ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Notes,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor.copy(alpha = 0.7f),
)
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
NodeBatteryPositionRow(
thatNode = thatNode,
distance = distance,
system = system,
contentColor = contentColor,
)
NodeSignalRow(thatNode = thatNode, isThisNode = isThisNode, 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 if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
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(
thatNode: Node,
isThisNode: Boolean,
longName: String,
style: FontStyle,
isIgnored: Boolean,
isFavorite: Boolean,
isMuted: Boolean,
isUnmessageable: Boolean,
connectionState: ConnectionState,
contentColor: Color,
) {
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(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = longName,
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
TransportIcon(
transport = thatNode.lastTransport,
viaMqtt = thatNode.viaMqtt,
modifier = Modifier.size(16.dp),
)
}
LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
}
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
isMuted = isMuted,
isUnmessageable = isUnmessageable,
connectionState = connectionState,
contentColor = contentColor,
)
}
}
@Composable
private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
HardwareInfo(hwModel = thatNode.user.hw_model.name, contentColor = contentColor)
RoleInfo(role = thatNode.user.role, contentColor = contentColor)
NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
}
}
@Composable
@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
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 NodeInfoStatusPreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode =
NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
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)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
distanceUnits = 1,
tempInFahrenheit = true,
connectionState = ConnectionState.Connected,
)
}
}

View file

@ -1,208 +0,0 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync
import org.meshtastic.core.ui.icon.CloudTwoTone
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Unmessageable
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
isMuted: Boolean,
connectionState: ConnectionState,
modifier: Modifier = Modifier,
contentColor: Color = LocalContentColor.current,
) {
Row(modifier = modifier.padding(4.dp)) {
if (isThisNode) {
ThisNodeStatusBadge(connectionState)
}
if (isUnmessageable) {
StatusBadge(
imageVector = MeshtasticIcons.Unmessageable,
contentDescription = Res.string.unmessageable,
tooltipText = Res.string.unmonitored_or_infrastructure,
tint = contentColor,
)
}
if (isMuted && !isThisNode) {
StatusBadge(
imageVector = MeshtasticIcons.VolumeOff,
contentDescription = Res.string.mute_always,
tooltipText = Res.string.mute_always,
tint = contentColor,
)
}
if (isFavorite && !isThisNode) {
StatusBadge(
imageVector = MeshtasticIcons.Favorite,
contentDescription = Res.string.favorite,
tooltipText = Res.string.favorite,
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
stringResource(
when (connectionState) {
ConnectionState.Connected -> Res.string.connected
ConnectionState.Connecting -> Res.string.connecting
ConnectionState.Disconnected -> Res.string.disconnected
ConnectionState.DeviceSleep -> Res.string.device_sleeping
},
),
)
}
},
state = rememberTooltipState(),
) {
when (connectionState) {
ConnectionState.Connected -> ConnectedStatusIcon()
ConnectionState.Connecting -> ConnectingStatusIcon()
ConnectionState.Disconnected -> DisconnectedStatusIcon()
ConnectionState.DeviceSleep -> DeviceSleepStatusIcon()
}
}
}
@Composable
private fun ConnectedStatusIcon() {
Icon(
imageVector = MeshtasticIcons.CloudDone,
contentDescription = stringResource(Res.string.connected),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusGreen,
)
}
@Composable
private fun ConnectingStatusIcon() {
Icon(
imageVector = MeshtasticIcons.CloudSync,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusOrange,
)
}
@Composable
private fun DisconnectedStatusIcon() {
Icon(
imageVector = MeshtasticIcons.CloudOffTwoTone,
contentDescription = stringResource(Res.string.disconnected),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusRed,
)
}
@Composable
private fun DeviceSleepStatusIcon() {
Icon(
imageVector = MeshtasticIcons.CloudTwoTone,
contentDescription = stringResource(Res.string.device_sleeping),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatusBadge(
imageVector: ImageVector,
contentDescription: StringResource,
tooltipText: StringResource,
tint: Color = LocalContentColor.current,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
state = rememberTooltipState(),
) {
Icon(
imageVector = imageVector,
contentDescription = stringResource(contentDescription),
modifier = Modifier.size(24.dp),
tint = tint,
)
}
}
@Preview
@Composable
private fun StatusIconsPreview() {
NodeStatusIcons(
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
isMuted = true,
connectionState = ConnectionState.Connected,
)
}

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
import org.meshtastic.core.resources.channel_3
import org.meshtastic.core.ui.component.InfoCard
import org.meshtastic.feature.node.model.VectorMetricInfo
/**

View file

@ -55,6 +55,9 @@ import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_telemetry
import org.meshtastic.core.resources.telemetry
import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.component.COOL_DOWN_TIME_MS
import org.meshtastic.core.ui.component.CooldownOutlinedIconButton
import org.meshtastic.core.ui.component.REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.LineAxis
import org.meshtastic.core.ui.icon.MeshtasticIcons

View file

@ -88,7 +88,7 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.core.ui.component.NodeItem
import org.meshtastic.proto.SharedContact
@OptIn(ExperimentalMaterial3ExpressiveApi::class)

View file

@ -55,7 +55,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateNeighborInfo
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.core.ui.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
@OptIn(ExperimentalFoundationApi::class)

View file

@ -73,7 +73,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.core.ui.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.RouteDiscovery