mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-01 22:19:18 +02:00
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:
+1
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+65
-62
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+17
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+124
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-146
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -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 {
|
||||
|
||||
+53
@@ -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)
|
||||
}
|
||||
|
||||
+73
-17
@@ -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(
|
||||
|
||||
+45
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+55
@@ -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" })
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+35
@@ -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())
|
||||
|
||||
+32
@@ -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)
|
||||
}
|
||||
|
||||
+278
@@ -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(),
|
||||
)
|
||||
+381
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user