feat: node list density switching with compact layout and field toggles (#5444)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-21 15:44:48 -07:00
committed by GitHub
parent 45608ced70
commit 5d9e71da39
85 changed files with 3573 additions and 356 deletions
@@ -296,6 +296,7 @@ fun MessageItem(
transport = message.transportMechanism,
viaMqtt = message.viaMqtt,
modifier = Modifier.size(16.dp).padding(start = 4.dp),
tint = Color.White,
)
}
if (containsBel) {
+5
View File
@@ -78,6 +78,11 @@
<ID>PreviewPublic:NodeDetailPreviews.kt:@PreviewLightDark @Composable fun NodeDetailContentLoadingPreview</ID>
<ID>PreviewPublic:NodeDetailPreviews.kt:@PreviewLightDark @Composable fun NodeDetailContentLocalPreview</ID>
<ID>PreviewPublic:NodeDetailPreviews.kt:@PreviewLightDark @Composable fun NodeDetailContentRemotePreview</ID>
<ID>PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompactActivePreview</ID>
<ID>PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompactAllFieldsPreview</ID>
<ID>PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompactMinimalPreview</ID>
<ID>PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompleteActivePreview</ID>
<ID>PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompletePreview</ID>
<ID>TooGenericExceptionCaught:MetricsViewModel.kt:MetricsViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:NodeManagementActions.kt:NodeManagementActions$ex: Exception</ID>
<ID>ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, )</ID>
@@ -17,23 +17,29 @@
package org.meshtastic.feature.node.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
@@ -41,62 +47,53 @@ import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.ic_unverified
import org.meshtastic.core.resources.img_hw_unknown
import org.meshtastic.core.resources.supported
import org.meshtastic.core.resources.supported_by_community
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.HardwareModel
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Verified
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.model.MetricsState
/**
* Device "hero" section showing the hardware image, device name, and support status. Used as the top section of the
* combined node identity card.
*/
@Composable
fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
val node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return
internal fun DeviceHeroSection(
bgColor: Long,
deviceHardware: DeviceHardware,
reportedTarget: String?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DeviceAvatar(bgColor, deviceHardware, size = 80)
SectionCard(title = Res.string.device, modifier = modifier) {
SelectionContainer {
Column {
Spacer(modifier = Modifier.height(16.dp))
Spacer(Modifier.width(16.dp))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
DeviceAvatar(node.colors.second.toLong(), deviceHardware)
}
Spacer(modifier = Modifier.height(16.dp))
SectionDivider()
val deviceText =
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
?: deviceHardware.displayName
ListItem(
text = stringResource(Res.string.hardware),
leadingIcon = MeshtasticIcons.HardwareModel,
supportingText = deviceText,
copyable = true,
trailingIcon = null,
)
SectionDivider()
SupportStatusItem(deviceHardware.activelySupported)
}
Column {
val deviceText = reportedTarget?.let { "${deviceHardware.displayName} ($it)" } ?: deviceHardware.displayName
Text(
text = deviceText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
SupportStatusBadge(deviceHardware.activelySupported)
}
}
}
@Composable
private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware) {
private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware, size: Int = 64) {
Box(
modifier =
Modifier.size(100.dp)
Modifier.size(size.dp)
.clip(CircleShape)
.background(color = Color(bgColor).copy(alpha = .5f), shape = CircleShape),
contentAlignment = Alignment.Center,
@@ -106,39 +103,45 @@ private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware) {
}
@Composable
private fun SupportStatusItem(isSupported: Boolean) {
ListItem(
text =
if (isSupported) {
stringResource(Res.string.supported)
} else {
stringResource(Res.string.supported_by_community)
},
leadingIcon =
if (isSupported) {
MeshtasticIcons.Verified
} else {
org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_unverified)
},
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
trailingIcon = null,
)
private fun SupportStatusBadge(isSupported: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Icon(
imageVector =
if (isSupported) {
MeshtasticIcons.Verified
} else {
org.jetbrains.compose.resources.vectorResource(Res.drawable.ic_unverified)
},
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
)
Spacer(Modifier.width(4.dp))
Text(
text =
if (isSupported) {
stringResource(Res.string.supported)
} else {
stringResource(Res.string.supported_by_community)
},
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
internal fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
val fallbackPainter = org.jetbrains.compose.resources.painterResource(Res.drawable.img_hw_unknown)
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current).data(imageUrl).build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
error =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
fallback =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
placeholder = fallbackPainter,
error = fallbackPainter,
fallback = fallbackPainter,
modifier = modifier.padding(16.dp),
)
}
@@ -166,3 +166,20 @@ fun NodeDetailsSectionPreview() {
val node = previewData.mickeyMouse
AppTheme { Surface { NodeDetailsSection(node = node) } }
}
@PreviewLightDark
@Composable
private fun NodeDetailsSectionWithDeviceHeroPreview() {
val node = previewData.mickeyMouse
val deviceHardware =
org.meshtastic.core.model.DeviceHardware(
displayName = "Heltec V3",
activelySupported = true,
images = listOf("heltec-v3.svg", "heltec-v3-case.svg"),
hwModel = 43,
hwModelSlug = "heltecV3",
)
AppTheme {
Surface { NodeDetailsSection(node = node, deviceHardware = deviceHardware, reportedTarget = "heltec-v3") }
}
}
@@ -18,6 +18,7 @@
package org.meshtastic.feature.node.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@@ -51,6 +52,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
@@ -92,13 +94,26 @@ import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.formatAgo
@Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
fun NodeDetailsSection(
node: Node,
modifier: Modifier = Modifier,
deviceHardware: DeviceHardware? = null,
reportedTarget: String? = null,
) {
SectionCard(title = Res.string.details, modifier = modifier) {
Column {
Column(modifier = Modifier.animateContentSize()) {
if (node.mismatchKey) {
MismatchKeyWarning(Modifier.padding(horizontal = 16.dp))
Spacer(Modifier.height(16.dp))
}
if (deviceHardware != null) {
DeviceHeroSection(
bgColor = node.colors.second.toLong(),
deviceHardware = deviceHardware,
reportedTarget = reportedTarget,
)
SectionDivider()
}
MainNodeDetails(node)
}
}
@@ -1,461 +0,0 @@
/*
* Copyright (c) 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 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.material3.Card
import androidx.compose.material3.CardDefaults
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.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
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.icon.AirUtilization
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Notes
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
@Composable
@Suppress("LongMethod")
fun NodeItem(
thisNode: Node?,
thatNode: Node,
distanceUnits: Int,
tempInFahrenheit: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
connectionState: ConnectionState,
deviceType: DeviceType? = null,
isActive: Boolean = false,
) {
val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val isFavorite = thatNode.isFavorite
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,
deviceType = deviceType,
contentColor = contentColor,
)
thatNode.nodeStatus?.let { status ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.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 = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f),
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 = vectorResource(quality.icon),
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 || pax.wifi != 0) {
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
}
if ((env.temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
items.add {
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)
}
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
PressureInfo(
pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f),
contentColor = contentColor,
)
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit)
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 = MetricFormatter.voltage(env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
}
}
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
value = MetricFormatter.current(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)) }
}
}
}
@Composable
private fun NodeItemHeader(
thatNode: Node,
isThisNode: Boolean,
longName: String,
style: FontStyle,
isIgnored: Boolean,
isFavorite: Boolean,
isMuted: Boolean,
isUnmessageable: Boolean,
connectionState: ConnectionState,
deviceType: DeviceType?,
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), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
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),
)
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,
deviceType = deviceType,
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)
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_layout_help_signal_bad
import org.meshtastic.core.resources.node_layout_help_signal_fair
import org.meshtastic.core.resources.node_layout_help_signal_good
import org.meshtastic.core.resources.node_layout_help_signal_indicator
import org.meshtastic.core.resources.node_layout_help_signal_none
import org.meshtastic.core.resources.node_layout_signal_quality_indicator
import org.meshtastic.core.resources.node_list_help_node_details
import org.meshtastic.core.resources.node_list_help_title
import org.meshtastic.core.ui.component.Quality
private const val ICON_SIZE = 24
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeListHelp(onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.node_list_help_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() },
)
HorizontalDivider()
Text(
text = stringResource(Res.string.node_list_help_node_details),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
SignalQualityEntry(Quality.GOOD, stringResource(Res.string.node_layout_help_signal_good))
SignalQualityEntry(Quality.FAIR, stringResource(Res.string.node_layout_help_signal_fair))
SignalQualityEntry(Quality.BAD, stringResource(Res.string.node_layout_help_signal_bad))
SignalQualityEntry(Quality.NONE, stringResource(Res.string.node_layout_help_signal_none))
HorizontalDivider()
Text(
text = stringResource(Res.string.node_layout_signal_quality_indicator),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(Res.string.node_layout_help_signal_indicator),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SignalQualityEntry(quality: Quality, description: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = vectorResource(quality.icon),
contentDescription = stringResource(quality.nameRes),
modifier = Modifier.size(ICON_SIZE.dp),
tint = quality.color(),
)
Column(modifier = Modifier.weight(1f)) {
Text(text = stringResource(quality.nameRes), style = MaterialTheme.typography.titleSmall)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,175 @@
/*
* Copyright (c) 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("TooManyFunctions", "MagicNumber", "PreviewPublic")
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.component.NodeItem
import org.meshtastic.core.ui.component.NodeItemCompact
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val previewNodes = NodePreviewParameterProvider()
// ---------------------------------------------------------------------------
// NodeItem (Complete density) previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
fun NodeItemCompletePreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = previewNodes.mickeyMouse,
thatNode = previewNodes.minnieMouse,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
)
}
}
}
}
@PreviewLightDark
@Composable
fun NodeItemCompleteActivePreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = previewNodes.mickeyMouse,
thatNode = previewNodes.mickeyMouse,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
isActive = true,
)
}
}
}
}
// ---------------------------------------------------------------------------
// NodeItemCompact previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
fun NodeItemCompactAllFieldsPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(
thisNode = previewNodes.mickeyMouse,
thatNode = previewNodes.minnieMouse,
distanceUnits = 0,
)
}
}
}
}
@PreviewLightDark
@Composable
fun NodeItemCompactMinimalPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(
thisNode = previewNodes.mickeyMouse,
thatNode = previewNodes.minnieMouse,
distanceUnits = 0,
showPower = false,
showLastHeard = false,
showLocation = false,
showHops = false,
showSignal = false,
showChannel = false,
showRole = false,
showTelemetry = false,
)
}
}
}
}
@PreviewLightDark
@Composable
fun NodeItemCompactActivePreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(
thisNode = previewNodes.mickeyMouse,
thatNode = previewNodes.mickeyMouse,
distanceUnits = 0,
isActive = true,
)
}
}
}
}
@PreviewLightDark
@Composable
fun NodeItemCompactOnlineRemotePreview() {
val onlineNode =
previewNodes.minnieMouse.copy(lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt())
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(thisNode = previewNodes.mickeyMouse, thatNode = onlineNode, distanceUnits = 0)
}
}
}
}
@PreviewLightDark
@Composable
fun NodeItemCompleteOnlineRemotePreview() {
val onlineNode =
previewNodes.minnieMouse.copy(lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt())
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = previewNodes.mickeyMouse,
thatNode = onlineNode,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
)
}
}
}
}
@@ -1,146 +0,0 @@
/*
* Copyright (c) 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.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
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.component.ConnectionsNavIcon
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.StatusYellow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
isMuted: Boolean,
connectionState: ConnectionState,
modifier: Modifier = Modifier,
deviceType: DeviceType? = null,
contentColor: Color = LocalContentColor.current,
) {
Row(modifier = modifier.padding(4.dp)) {
if (isThisNode) {
ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType)
}
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, deviceType: DeviceType?) {
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(),
) {
ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatusBadge(
imageVector: ImageVector,
contentDescription: StringResource,
tooltipText: StringResource,
tint: Color = LocalContentColor.current,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
state = rememberTooltipState(),
) {
Icon(
imageVector = imageVector,
contentDescription = stringResource(contentDescription),
modifier = Modifier.size(24.dp),
tint = tint,
)
}
}
@@ -41,7 +41,6 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.loading
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -103,7 +102,13 @@ fun NodeDetailList(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { NodeDetailsSection(node) }
item {
NodeDetailsSection(
node = node,
deviceHardware = uiState.metricsState.deviceHardware,
reportedTarget = uiState.metricsState.reportedTarget,
)
}
item {
DeviceActions(
node = node,
@@ -117,9 +122,6 @@ fun NodeDetailList(
isLocal = uiState.metricsState.isLocal,
)
}
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
if (!uiState.metricsState.isManaged) {
item {
@@ -22,6 +22,7 @@ import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.UiPrefs
@Single
@Suppress("TooManyFunctions")
open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val includeUnknown = uiPrefs.includeUnknown
open val excludeInfrastructure = uiPrefs.excludeInfrastructure
@@ -30,6 +31,18 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val showIgnored = uiPrefs.showIgnored
open val excludeMqtt = uiPrefs.excludeMqtt
// Node list layout preferences
open val nodeListDensity = uiPrefs.nodeListDensity
open val shouldShowPower = uiPrefs.shouldShowPower
open val shouldShowLastHeard = uiPrefs.shouldShowLastHeard
open val lastHeardIsRelative = uiPrefs.lastHeardIsRelative
open val shouldShowLocation = uiPrefs.shouldShowLocation
open val shouldShowHops = uiPrefs.shouldShowHops
open val shouldShowSignal = uiPrefs.shouldShowSignal
open val shouldShowChannel = uiPrefs.shouldShowChannel
open val shouldShowRole = uiPrefs.shouldShowRole
open val shouldShowTelemetry = uiPrefs.shouldShowTelemetry
open val nodeSortOption =
uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
@@ -37,6 +50,46 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
uiPrefs.setNodeSort(option.ordinal)
}
open fun setNodeListDensity(value: String) {
uiPrefs.setNodeListDensity(value)
}
open fun setShouldShowPower(value: Boolean) {
uiPrefs.setShouldShowPower(value)
}
open fun setShouldShowLastHeard(value: Boolean) {
uiPrefs.setShouldShowLastHeard(value)
}
open fun setLastHeardIsRelative(value: Boolean) {
uiPrefs.setLastHeardIsRelative(value)
}
open fun setShouldShowLocation(value: Boolean) {
uiPrefs.setShouldShowLocation(value)
}
open fun setShouldShowHops(value: Boolean) {
uiPrefs.setShouldShowHops(value)
}
open fun setShouldShowSignal(value: Boolean) {
uiPrefs.setShouldShowSignal(value)
}
open fun setShouldShowChannel(value: Boolean) {
uiPrefs.setShouldShowChannel(value)
}
open fun setShouldShowRole(value: Boolean) {
uiPrefs.setShouldShowRole(value)
}
open fun setShouldShowTelemetry(value: Boolean) {
uiPrefs.setShouldShowTelemetry(value)
}
open fun toggleIncludeUnknown() {
uiPrefs.setIncludeUnknown(!includeUnknown.value)
}
@@ -14,8 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("detekt:ALL")
package org.meshtastic.feature.node.list
import androidx.compose.animation.core.animateFloatAsState
@@ -36,6 +34,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -60,9 +59,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.node_list_help_title
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.nodes_empty_disconnected_hint
import org.meshtastic.core.resources.nodes_empty_disconnected_title
@@ -71,14 +72,17 @@ import org.meshtastic.core.resources.nodes_empty_searching_title
import org.meshtastic.core.resources.set_up_connection
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.NodeItem
import org.meshtastic.core.ui.component.NodeItemCompact
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.feature.node.component.NodeListHelp
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -86,7 +90,7 @@ import org.meshtastic.feature.node.component.NodeItem
fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel,
onNavigateToChannels: () -> Unit = {},
modifier: Modifier = Modifier,
navigateToMessages: (String) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
@@ -102,6 +106,7 @@ fun NodeListScreen(
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val deviceImageUrls by viewModel.deviceImageUrls.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val listState = rememberLazyListState()
@@ -118,11 +123,28 @@ fun NodeListScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val deviceType by viewModel.deviceType.collectAsStateWithLifecycle()
val density by viewModel.nodeListDensity.collectAsStateWithLifecycle()
val showPower by viewModel.shouldShowPower.collectAsStateWithLifecycle()
val showLastHeard by viewModel.shouldShowLastHeard.collectAsStateWithLifecycle()
val lastHeardIsRelative by viewModel.lastHeardIsRelative.collectAsStateWithLifecycle()
val showLocation by viewModel.shouldShowLocation.collectAsStateWithLifecycle()
val showHops by viewModel.shouldShowHops.collectAsStateWithLifecycle()
val showSignal by viewModel.shouldShowSignal.collectAsStateWithLifecycle()
val showChannel by viewModel.shouldShowChannel.collectAsStateWithLifecycle()
val showRole by viewModel.shouldShowRole.collectAsStateWithLifecycle()
val showTelemetry by viewModel.shouldShowTelemetry.collectAsStateWithLifecycle()
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
var showHelpSheet by remember { mutableStateOf(false) }
if (showHelpSheet) {
NodeListHelp(onDismiss = { showHelpSheet = false })
}
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.nodes),
@@ -131,7 +153,14 @@ fun NodeListScreen(
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
actions = {
IconButton(onClick = { showHelpSheet = true }) {
Icon(
imageVector = MeshtasticIcons.Info,
contentDescription = stringResource(Res.string.node_list_help_title),
)
}
},
onClickChip = {},
)
},
@@ -198,18 +227,45 @@ fun NodeListScreen(
val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
connectionState = connectionState,
deviceType = deviceType,
isActive = isActive,
)
when (density) {
NodeListDensity.COMPLETE ->
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
connectionState = connectionState,
deviceType = deviceType,
isActive = isActive,
showTelemetry = showTelemetry,
deviceImageUrl = deviceImageUrls[node.user.hw_model.value],
)
NodeListDensity.COMPACT ->
NodeItemCompact(
modifier = Modifier.animateItem(),
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
isActive = isActive,
showPower = showPower,
showLastHeard = showLastHeard,
lastHeardIsRelative = lastHeardIsRelative,
showLocation = showLocation,
showHops = showHops,
showSignal = showSignal,
showChannel = showChannel,
showRole = showRole,
showTelemetry = showTelemetry,
tempInFahrenheit = state.tempInFahrenheit,
deviceImageUrl = deviceImageUrls[node.user.hw_model.value],
)
}
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
NodeContextMenu(
@@ -20,7 +20,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -29,8 +31,10 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
@@ -51,6 +55,7 @@ class NodeListViewModel(
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val radioInterfaceService: RadioInterfaceService,
private val deviceHardwareRepository: DeviceHardwareRepository,
val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,
@@ -70,6 +75,21 @@ class NodeListViewModel(
.map { address -> address?.let { DeviceType.fromAddress(it) } }
.stateInWhileSubscribed(initialValue = null)
val nodeListDensity: StateFlow<NodeListDensity> =
nodeFilterPreferences.nodeListDensity
.map { name -> NodeListDensity.fromName(name) }
.stateInWhileSubscribed(initialValue = NodeListDensity.COMPLETE)
val shouldShowPower = nodeFilterPreferences.shouldShowPower
val shouldShowLastHeard = nodeFilterPreferences.shouldShowLastHeard
val lastHeardIsRelative = nodeFilterPreferences.lastHeardIsRelative
val shouldShowLocation = nodeFilterPreferences.shouldShowLocation
val shouldShowHops = nodeFilterPreferences.shouldShowHops
val shouldShowSignal = nodeFilterPreferences.shouldShowSignal
val shouldShowChannel = nodeFilterPreferences.shouldShowChannel
val shouldShowRole = nodeFilterPreferences.shouldShowRole
val shouldShowTelemetry = nodeFilterPreferences.shouldShowTelemetry
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@@ -126,6 +146,30 @@ class NodeListViewModel(
val unfilteredNodeList: StateFlow<List<Node>> =
nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList())
private val _deviceImageUrls = MutableStateFlow<Map<Int, String>>(emptyMap())
/** Maps hw_model int value → device image URL from the flasher CDN. */
val deviceImageUrls: StateFlow<Map<Int, String>> = _deviceImageUrls.asStateFlow()
init {
// Resolve device image URLs as nodes arrive
viewModelScope.launch {
nodeList.collect { nodes ->
val newModels = nodes.map { it.user.hw_model.value }.distinct().filter { it !in _deviceImageUrls.value }
for (hwModel in newModels) {
resolveDeviceImageUrl(hwModel)
}
}
}
}
private suspend fun resolveDeviceImageUrl(hwModel: Int) {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull() ?: return
val imageFile = hw.images?.getOrNull(1) ?: hw.images?.getOrNull(0) ?: return
val url = "$FLASHER_DEVICE_IMAGE_BASE_URL$imageFile"
_deviceImageUrls.value = _deviceImageUrls.value + (hwModel to url)
}
var nodeFilterText: String
get() = _nodeFilterText.value
set(value) {
@@ -167,6 +211,7 @@ class NodeListViewModel(
companion object {
private const val KEY_FILTER_TEXT = "filter_text"
private const val FLASHER_DEVICE_IMAGE_BASE_URL = "https://flasher.meshtastic.org/img/devices/"
}
}
@@ -21,7 +21,6 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoute
import org.meshtastic.core.navigation.ContactsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.ui.component.ScrollToTopEvent
@@ -40,7 +39,6 @@ fun AdaptiveNodeListScreen(
NodeListScreen(
viewModel = nodeListViewModel,
navigateToNodeDetails = { nodeId -> backStack.add(NodesRoute.NodeDetail(nodeId)) },
onNavigateToChannels = { backStack.add(ChannelsRoute.Channels) },
navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = null,
@@ -0,0 +1,55 @@
/*
* Copyright (c) 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 org.meshtastic.core.model.NodeListDensity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class NodeListDensityTest {
@Test
fun valid_complete_string_maps_to_complete() {
assertEquals(NodeListDensity.COMPLETE, NodeListDensity.fromName("COMPLETE"))
}
@Test
fun valid_compact_string_maps_to_compact() {
assertEquals(NodeListDensity.COMPACT, NodeListDensity.fromName("COMPACT"))
}
@Test
fun invalid_string_falls_back_to_complete() {
assertEquals(NodeListDensity.COMPLETE, NodeListDensity.fromName("GARBAGE"))
}
@Test
fun empty_string_falls_back_to_complete() {
assertEquals(NodeListDensity.COMPLETE, NodeListDensity.fromName(""))
}
@Test
fun lowercase_does_not_match_and_falls_back() {
assertEquals(NodeListDensity.COMPLETE, NodeListDensity.fromName("compact"))
}
@Test
fun firstOrNull_returns_null_for_unknown() {
assertNull(NodeListDensity.entries.firstOrNull { it.name == "UNKNOWN" })
}
}
@@ -30,6 +30,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeDeviceHardwareRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeRadioInterfaceService
@@ -85,6 +86,7 @@ class NodeListViewModelTest {
serviceRepository = serviceRepository,
radioController = radioController,
radioInterfaceService = radioInterfaceService,
deviceHardwareRepository = FakeDeviceHardwareRepository(),
nodeManagementActions = nodeManagementActions,
nodeRequestActions = nodeRequestActions,
getFilteredNodesUseCase = getFilteredNodesUseCase,
+2
View File
@@ -102,6 +102,8 @@
<ID>ParameterNaming:PositionConfigScreen.kt:onLocationReceived: (Position) -> Unit</ID>
<ID>PreviewPublic:AppInfoSection.kt:@Preview(showBackground = true) @Composable fun AppInfoSectionPreview</ID>
<ID>PreviewPublic:AppearanceSection.kt:@Preview(showBackground = true) @Composable fun AppearanceSectionPreview</ID>
<ID>PreviewPublic:NodeLayoutSettingsPreviews.kt:@PreviewLightDark @Composable fun NodeLayoutSettingsCompactPreview</ID>
<ID>PreviewPublic:NodeLayoutSettingsPreviews.kt:@PreviewLightDark @Composable fun NodeLayoutSettingsCompletePreview</ID>
<ID>PreviewPublic:PersistenceSection.kt:@Preview(showBackground = true) @Composable fun PersistenceSectionPreview</ID>
<ID>ReturnCount:RadioConfigViewModel.kt:RadioConfigViewModel$private fun processPacketResponse</ID>
<ID>TooGenericExceptionCaught:DebugViewModel.kt:DebugViewModel$e: Exception</ID>
@@ -62,6 +62,7 @@ import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.NodeLayoutSettings
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.component.ThemePickerDialog
@@ -240,6 +241,40 @@ fun SettingsScreen(
onShowThemePicker = { showThemePickerDialog = true },
)
val densityName by settingsViewModel.nodeListDensity.collectAsStateWithLifecycle()
val density = org.meshtastic.core.model.NodeListDensity.fromName(densityName)
val showPower by settingsViewModel.shouldShowPower.collectAsStateWithLifecycle()
val showLastHeard by settingsViewModel.shouldShowLastHeard.collectAsStateWithLifecycle()
val lastHeardRelative by settingsViewModel.lastHeardIsRelative.collectAsStateWithLifecycle()
val showLocation by settingsViewModel.shouldShowLocation.collectAsStateWithLifecycle()
val showHops by settingsViewModel.shouldShowHops.collectAsStateWithLifecycle()
val showSignal by settingsViewModel.shouldShowSignal.collectAsStateWithLifecycle()
val showChannel by settingsViewModel.shouldShowChannel.collectAsStateWithLifecycle()
val showRole by settingsViewModel.shouldShowRole.collectAsStateWithLifecycle()
val showTelemetry by settingsViewModel.shouldShowTelemetry.collectAsStateWithLifecycle()
NodeLayoutSettings(
density = density,
onDensityChange = { settingsViewModel.setNodeListDensity(it.name) },
showPower = showPower,
onShowPowerChange = { settingsViewModel.setShouldShowPower(it) },
showLastHeard = showLastHeard,
onShowLastHeardChange = { settingsViewModel.setShouldShowLastHeard(it) },
lastHeardIsRelative = lastHeardRelative,
onLastHeardIsRelativeChange = { settingsViewModel.setLastHeardIsRelative(it) },
showLocation = showLocation,
onShowLocationChange = { settingsViewModel.setShouldShowLocation(it) },
showHops = showHops,
onShowHopsChange = { settingsViewModel.setShouldShowHops(it) },
showSignal = showSignal,
onShowSignalChange = { settingsViewModel.setShouldShowSignal(it) },
showChannel = showChannel,
onShowChannelChange = { settingsViewModel.setShouldShowChannel(it) },
showRole = showRole,
onShowRoleChange = { settingsViewModel.setShouldShowRole(it) },
showTelemetry = showTelemetry,
onShowTelemetryChange = { settingsViewModel.setShouldShowTelemetry(it) },
)
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())
@@ -191,4 +191,36 @@ class SettingsViewModel(
val myNodeNum = myNodeNum ?: return
exportDataUseCase(writer, myNodeNum, filterPortnum)
}
// Node list layout preferences
val nodeListDensity = uiPrefs.nodeListDensity
val shouldShowPower = uiPrefs.shouldShowPower
val shouldShowLastHeard = uiPrefs.shouldShowLastHeard
val lastHeardIsRelative = uiPrefs.lastHeardIsRelative
val shouldShowLocation = uiPrefs.shouldShowLocation
val shouldShowHops = uiPrefs.shouldShowHops
val shouldShowSignal = uiPrefs.shouldShowSignal
val shouldShowChannel = uiPrefs.shouldShowChannel
val shouldShowRole = uiPrefs.shouldShowRole
val shouldShowTelemetry = uiPrefs.shouldShowTelemetry
fun setNodeListDensity(value: String) = uiPrefs.setNodeListDensity(value)
fun setShouldShowPower(value: Boolean) = uiPrefs.setShouldShowPower(value)
fun setShouldShowLastHeard(value: Boolean) = uiPrefs.setShouldShowLastHeard(value)
fun setLastHeardIsRelative(value: Boolean) = uiPrefs.setLastHeardIsRelative(value)
fun setShouldShowLocation(value: Boolean) = uiPrefs.setShouldShowLocation(value)
fun setShouldShowHops(value: Boolean) = uiPrefs.setShouldShowHops(value)
fun setShouldShowSignal(value: Boolean) = uiPrefs.setShouldShowSignal(value)
fun setShouldShowChannel(value: Boolean) = uiPrefs.setShouldShowChannel(value)
fun setShouldShowRole(value: Boolean) = uiPrefs.setShouldShowRole(value)
fun setShouldShowTelemetry(value: Boolean) = uiPrefs.setShouldShowTelemetry(value)
}
@@ -0,0 +1,278 @@
/*
* Copyright (c) 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.settings.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_layout_channel
import org.meshtastic.core.resources.node_layout_compact
import org.meshtastic.core.resources.node_layout_compact_fields_header
import org.meshtastic.core.resources.node_layout_complete
import org.meshtastic.core.resources.node_layout_complete_description
import org.meshtastic.core.resources.node_layout_device_role
import org.meshtastic.core.resources.node_layout_distance_and_bearing
import org.meshtastic.core.resources.node_layout_hops_away
import org.meshtastic.core.resources.node_layout_last_heard_time
import org.meshtastic.core.resources.node_layout_log_icons
import org.meshtastic.core.resources.node_layout_power
import org.meshtastic.core.resources.node_layout_preview
import org.meshtastic.core.resources.node_layout_relative_last_heard
import org.meshtastic.core.resources.node_layout_section_title
import org.meshtastic.core.resources.node_layout_signal_direct_only
import org.meshtastic.core.ui.component.NodeItem
import org.meshtastic.core.ui.component.NodeItemCompact
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
/** Node layout density picker and compact field toggles for the Settings screen. */
@Composable
@Suppress("LongParameterList", "LongMethod")
fun NodeLayoutSettings(
density: NodeListDensity,
onDensityChange: (NodeListDensity) -> Unit,
showPower: Boolean,
onShowPowerChange: (Boolean) -> Unit,
showLastHeard: Boolean,
onShowLastHeardChange: (Boolean) -> Unit,
lastHeardIsRelative: Boolean,
onLastHeardIsRelativeChange: (Boolean) -> Unit,
showLocation: Boolean,
onShowLocationChange: (Boolean) -> Unit,
showHops: Boolean,
onShowHopsChange: (Boolean) -> Unit,
showSignal: Boolean,
onShowSignalChange: (Boolean) -> Unit,
showChannel: Boolean,
onShowChannelChange: (Boolean) -> Unit,
showRole: Boolean,
onShowRoleChange: (Boolean) -> Unit,
showTelemetry: Boolean,
onShowTelemetryChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
ExpressiveSection(modifier = modifier, title = stringResource(Res.string.node_layout_section_title)) {
// Density picker
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
NodeListDensity.entries.forEachIndexed { index, option ->
val label =
when (option) {
NodeListDensity.COMPLETE -> stringResource(Res.string.node_layout_complete)
NodeListDensity.COMPACT -> stringResource(Res.string.node_layout_compact)
}
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, NodeListDensity.entries.size),
onClick = { onDensityChange(option) },
selected = density == option,
label = { Text(label) },
)
}
}
// Live preview — positioned above toggles so it doesn't jump with list size changes
Text(
text = stringResource(Res.string.node_layout_preview),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
)
val previewNode = remember { previewSampleNode() }
val localNode = remember { previewLocalNode() }
Box(modifier = Modifier.animateContentSize().padding(bottom = 8.dp)) {
when (density) {
NodeListDensity.COMPLETE ->
NodeItem(
thisNode = localNode,
thatNode = previewNode,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
showTelemetry = showTelemetry,
)
NodeListDensity.COMPACT ->
NodeItemCompact(
thisNode = localNode,
thatNode = previewNode,
distanceUnits = 0,
showPower = showPower,
showLastHeard = showLastHeard,
lastHeardIsRelative = lastHeardIsRelative,
showLocation = showLocation,
showHops = showHops,
showSignal = showSignal,
showChannel = showChannel,
showRole = showRole,
showTelemetry = showTelemetry,
)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(4.dp))
// Shared toggle — applies to both layouts
SwitchPreference(
title = stringResource(Res.string.node_layout_log_icons),
checked = showTelemetry,
enabled = true,
onCheckedChange = onShowTelemetryChange,
)
if (density == NodeListDensity.COMPLETE) {
Text(
text = stringResource(Res.string.node_layout_complete_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
// Compact-specific toggles
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
Text(
text = stringResource(Res.string.node_layout_compact_fields_header),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
)
SwitchPreference(
title = stringResource(Res.string.node_layout_power),
checked = showPower,
enabled = true,
onCheckedChange = onShowPowerChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_last_heard_time),
checked = showLastHeard,
enabled = true,
onCheckedChange = onShowLastHeardChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_relative_last_heard),
checked = lastHeardIsRelative,
enabled = showLastHeard,
onCheckedChange = onLastHeardIsRelativeChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_distance_and_bearing),
checked = showLocation,
enabled = true,
onCheckedChange = onShowLocationChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_hops_away),
checked = showHops,
enabled = true,
onCheckedChange = onShowHopsChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_signal_direct_only),
checked = showSignal,
enabled = true,
onCheckedChange = onShowSignalChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_channel),
checked = showChannel,
enabled = true,
onCheckedChange = onShowChannelChange,
)
SwitchPreference(
title = stringResource(Res.string.node_layout_device_role),
checked = showRole,
enabled = true,
onCheckedChange = onShowRoleChange,
)
}
}
}
@Suppress("MagicNumber")
internal fun previewSampleNode(hopsAway: Int = 1): Node = Node(
num = 0x1A2B3C4D,
user =
User(
id = "!1a2b3c4d",
long_name = "Solar Hilltop",
short_name = "SoHi",
hw_model = HardwareModel.TBEAM,
role = Config.DeviceConfig.Role.ROUTER,
public_key = ByteArray(32) { (it * 7).toByte() }.toByteString(),
),
position = Position(latitude_i = 338125110, longitude_i = -1179189760, altitude = 138, sats_in_view = 8),
lastHeard = (nowSeconds - 300).toInt(),
channel = 1,
snr = 10.25F,
rssi = -67,
deviceMetrics =
DeviceMetrics(
channel_utilization = 3.2F,
air_util_tx = 1.8F,
battery_level = 92,
voltage = 4.1F,
uptime_seconds = 86400,
),
environmentMetrics =
EnvironmentMetrics(temperature = 24.5F, relative_humidity = 45.0F, barometric_pressure = 1013.25F),
isFavorite = true,
hopsAway = hopsAway,
)
/** Local device node used as reference point for distance calculation in previews. */
@Suppress("MagicNumber")
internal fun previewLocalNode(): Node = Node(
num = 0xDEADBEEF.toInt(),
user =
User(
id = "!deadbeef",
long_name = "My Radio",
short_name = "MyRd",
hw_model = HardwareModel.HELTEC_V3,
role = Config.DeviceConfig.Role.CLIENT,
),
position = Position(latitude_i = 338000000, longitude_i = -1179000000, altitude = 50, sats_in_view = 10),
lastHeard = (nowSeconds - 30).toInt(),
)
@@ -0,0 +1,381 @@
/*
* Copyright (c) 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.settings.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.ui.component.NodeItem
import org.meshtastic.core.ui.component.NodeItemCompact
import org.meshtastic.core.ui.theme.AppTheme
@PreviewLightDark
@Composable
fun NodeLayoutSettingsCompactPreview() {
AppTheme {
Surface {
NodeLayoutSettings(
density = NodeListDensity.COMPACT,
onDensityChange = {},
showPower = true,
onShowPowerChange = {},
showLastHeard = true,
onShowLastHeardChange = {},
lastHeardIsRelative = true,
onLastHeardIsRelativeChange = {},
showLocation = true,
onShowLocationChange = {},
showHops = true,
onShowHopsChange = {},
showSignal = true,
onShowSignalChange = {},
showChannel = false,
onShowChannelChange = {},
showRole = true,
onShowRoleChange = {},
showTelemetry = true,
onShowTelemetryChange = {},
)
}
}
}
@PreviewLightDark
@Composable
fun NodeLayoutSettingsCompletePreview() {
AppTheme {
Surface {
NodeLayoutSettings(
density = NodeListDensity.COMPLETE,
onDensityChange = {},
showPower = true,
onShowPowerChange = {},
showLastHeard = true,
onShowLastHeardChange = {},
lastHeardIsRelative = true,
onLastHeardIsRelativeChange = {},
showLocation = true,
onShowLocationChange = {},
showHops = true,
onShowHopsChange = {},
showSignal = true,
onShowSignalChange = {},
showChannel = true,
onShowChannelChange = {},
showRole = true,
onShowRoleChange = {},
showTelemetry = true,
onShowTelemetryChange = {},
)
}
}
}
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun NodeLayoutSettingsCompactMinimalPreview() {
AppTheme {
Surface {
NodeLayoutSettings(
density = NodeListDensity.COMPACT,
onDensityChange = {},
showPower = false,
onShowPowerChange = {},
showLastHeard = true,
onShowLastHeardChange = {},
lastHeardIsRelative = true,
onLastHeardIsRelativeChange = {},
showLocation = false,
onShowLocationChange = {},
showHops = false,
onShowHopsChange = {},
showSignal = true,
onShowSignalChange = {},
showChannel = false,
onShowChannelChange = {},
showRole = false,
onShowRoleChange = {},
showTelemetry = false,
onShowTelemetryChange = {},
)
}
}
}
// ---------------------------------------------------------------------------
// Isolated sample node previews — the preview node used in settings
// ---------------------------------------------------------------------------
private val sampleNode = previewSampleNode()
private val localNode = previewLocalNode()
/** Sample node rendered in Complete density (all fields visible). */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompletePreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = localNode,
thatNode = sampleNode,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
)
}
}
}
}
/** Sample node rendered in Compact density with all fields enabled. */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompactAllFieldsPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(thisNode = localNode, thatNode = sampleNode, distanceUnits = 0)
}
}
}
}
/** Sample node in Compact with only signal + last heard (absolute time). */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompactSignalOnlyPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(
thisNode = localNode,
thatNode = previewSampleNode(hopsAway = 0),
distanceUnits = 0,
showPower = false,
showLastHeard = true,
lastHeardIsRelative = true,
showLocation = false,
showHops = false,
showSignal = true,
showChannel = false,
showRole = false,
showTelemetry = false,
)
}
}
}
}
/** Sample node in Compact with no optional fields — name row only. */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompactNameOnlyPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItemCompact(
thisNode = localNode,
thatNode = sampleNode,
distanceUnits = 0,
showPower = false,
showLastHeard = false,
showLocation = false,
showHops = false,
showSignal = false,
showChannel = false,
showRole = false,
showTelemetry = false,
)
}
}
}
}
/**
* Matrix view showing compact node item in various toggle states. Each row: label describing active toggles rendered
* node item.
*/
@Suppress("PreviewPublic", "LongMethod")
@PreviewLightDark
@Composable
fun SampleNodeCompactToggleMatrixPreview() {
val node = previewSampleNode(hopsAway = 0)
val local = previewLocalNode()
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
MatrixRow("All fields") { NodeItemCompact(thisNode = local, thatNode = node, distanceUnits = 0) }
MatrixRow("Health only") {
NodeItemCompact(
thisNode = local,
thatNode = node,
distanceUnits = 0,
showHops = false,
showChannel = false,
showRole = false,
showTelemetry = false,
)
}
MatrixRow("No metrics") {
NodeItemCompact(thisNode = local, thatNode = node, distanceUnits = 0, showTelemetry = false)
}
MatrixRow("No footer") {
NodeItemCompact(
thisNode = local,
thatNode = node,
distanceUnits = 0,
showHops = false,
showChannel = false,
showRole = false,
)
}
MatrixRow("Metrics + footer") {
NodeItemCompact(
thisNode = local,
thatNode = node,
distanceUnits = 0,
showPower = false,
showLastHeard = false,
showLocation = false,
showSignal = false,
)
}
MatrixRow("Minimal (name only)") {
NodeItemCompact(
thisNode = local,
thatNode = node,
distanceUnits = 0,
showPower = false,
showLastHeard = false,
showLocation = false,
showHops = false,
showSignal = false,
showChannel = false,
showRole = false,
showTelemetry = false,
)
}
}
}
}
}
/** Matrix view showing complete node item with/without metrics toggle. */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompleteToggleMatrixPreview() {
val node = previewSampleNode()
val local = previewLocalNode()
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
MatrixRow("With metrics") {
NodeItem(
thisNode = local,
thatNode = node,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
showTelemetry = true,
)
}
MatrixRow("Without metrics") {
NodeItem(
thisNode = local,
thatNode = node,
distanceUnits = 0,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
showTelemetry = false,
)
}
}
}
}
}
@Composable
private fun MatrixRow(label: String, content: @Composable () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp),
)
content()
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
/** Sample node in Complete density with Fahrenheit temperature. */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompleteFahrenheitPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = localNode,
thatNode = sampleNode,
distanceUnits = 0,
tempInFahrenheit = true,
connectionState = ConnectionState.Connected,
)
}
}
}
}
/** Sample node in Complete density with Imperial units. */
@Suppress("PreviewPublic")
@PreviewLightDark
@Composable
fun SampleNodeCompleteImperialPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
NodeItem(
thisNode = localNode,
thatNode = sampleNode,
distanceUnits = 1,
tempInFahrenheit = true,
connectionState = ConnectionState.Connected,
)
}
}
}
}
@@ -71,6 +71,7 @@ import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NodeLayoutSettings
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -202,6 +203,40 @@ fun DesktopSettingsScreen(
)
}
val densityName by settingsViewModel.nodeListDensity.collectAsStateWithLifecycle()
val density = org.meshtastic.core.model.NodeListDensity.fromName(densityName)
val showPower by settingsViewModel.shouldShowPower.collectAsStateWithLifecycle()
val showLastHeard by settingsViewModel.shouldShowLastHeard.collectAsStateWithLifecycle()
val lastHeardRelative by settingsViewModel.lastHeardIsRelative.collectAsStateWithLifecycle()
val showLocation by settingsViewModel.shouldShowLocation.collectAsStateWithLifecycle()
val showHops by settingsViewModel.shouldShowHops.collectAsStateWithLifecycle()
val showSignal by settingsViewModel.shouldShowSignal.collectAsStateWithLifecycle()
val showChannel by settingsViewModel.shouldShowChannel.collectAsStateWithLifecycle()
val showRole by settingsViewModel.shouldShowRole.collectAsStateWithLifecycle()
val showTelemetry by settingsViewModel.shouldShowTelemetry.collectAsStateWithLifecycle()
NodeLayoutSettings(
density = density,
onDensityChange = { settingsViewModel.setNodeListDensity(it.name) },
showPower = showPower,
onShowPowerChange = { settingsViewModel.setShouldShowPower(it) },
showLastHeard = showLastHeard,
onShowLastHeardChange = { settingsViewModel.setShouldShowLastHeard(it) },
lastHeardIsRelative = lastHeardRelative,
onLastHeardIsRelativeChange = { settingsViewModel.setLastHeardIsRelative(it) },
showLocation = showLocation,
onShowLocationChange = { settingsViewModel.setShouldShowLocation(it) },
showHops = showHops,
onShowHopsChange = { settingsViewModel.setShouldShowHops(it) },
showSignal = showSignal,
onShowSignalChange = { settingsViewModel.setShouldShowSignal(it) },
showChannel = showChannel,
onShowChannelChange = { settingsViewModel.setShouldShowChannel(it) },
showRole = showRole,
onShowRoleChange = { settingsViewModel.setShouldShowRole(it) },
showTelemetry = showTelemetry,
onShowTelemetryChange = { settingsViewModel.setShouldShowTelemetry(it) },
)
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())