feat: introduce Desktop target and expand Kotlin Multiplatform (KMP) architecture (#4761)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-12 16:14:49 -05:00 committed by GitHub
parent f4364cff9a
commit ac6bb5479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
386 changed files with 17089 additions and 4590 deletions

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.node.compass
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
@ -36,6 +37,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
PhoneLocationProvider {
@SuppressLint("MissingPermission")
override fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
if (locationManager == null) {
@ -91,7 +93,7 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
sendUpdate()
providers.forEach { provider ->
if (locationManager.getProvider(provider) != null) {
if (provider in locationManager.allProviders) {
LocationManagerCompat.requestLocationUpdates(
locationManager,
provider,

View file

@ -21,16 +21,8 @@ import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@ -44,11 +36,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -60,22 +49,15 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@ -161,7 +143,6 @@ private fun NodeDetailScaffold(
) { paddingValues ->
NodeDetailContent(
uiState = uiState,
viewModel = viewModel,
listState = listState,
onAction = { action ->
when (action) {
@ -182,6 +163,7 @@ private fun NodeDetailScaffold(
}
},
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
modifier = Modifier.padding(paddingValues),
)
}
@ -191,35 +173,6 @@ private fun NodeDetailScaffold(
}
}
@Composable
private fun NodeDetailContent(
uiState: NodeDetailUiState,
viewModel: NodeDetailViewModel,
listState: LazyListState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
if (isNodePresent && uiState.node != null) {
NodeDetailList(
node = uiState.node,
ourNode = uiState.ourNode,
uiState = uiState,
listState = listState,
onAction = onAction,
onFirmwareSelect = onFirmwareSelect,
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
} else {
val loadingDescription = stringResource(Res.string.loading)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailOverlays(
@ -276,46 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}
@Composable
private fun NodeDetailList(
node: Node,
ourNode: Node?,
uiState: NodeDetailUiState,
listState: LazyListState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { NodeDetailsSection(node) }
item {
DeviceActions(
node = node,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
isLocal = uiState.metricsState.isLocal,
)
}
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
if (!uiState.metricsState.isManaged) {
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
}
}
}
private fun handleNodeAction(
action: NodeDetailAction,
uiState: NodeDetailUiState,

View file

@ -30,22 +30,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.DoDisturbOn
import androidx.compose.material.icons.outlined.DoDisturbOn
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -57,7 +44,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@ -67,25 +53,17 @@ 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.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.SharedContact
@ -221,7 +199,7 @@ fun NodeListScreen(
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
ContextMenu(
NodeContextMenu(
expanded = expanded,
node = node,
onFavorite = { viewModel.favoriteNode(node) },
@ -238,108 +216,3 @@ fun NodeListScreen(
}
}
}
@Composable
private fun ContextMenu(
expanded: Boolean,
node: Node,
onFavorite: () -> Unit,
onIgnore: () -> Unit,
onMute: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
FavoriteMenuItem(node, onFavorite, onDismiss)
IgnoreMenuItem(node, onIgnore, onDismiss)
if (node.capabilities.canMuteNode) {
MuteMenuItem(node, onMute, onDismiss)
}
RemoveMenuItem(node, onRemove, onDismiss)
}
}
@Composable
private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
val isFavorite = node.isFavorite
DropdownMenuItem(
onClick = {
onFavorite()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = null,
)
},
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
)
}
@Composable
private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
val isIgnored = node.isIgnored
DropdownMenuItem(
onClick = {
onIgnore()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
color = MaterialTheme.colorScheme.StatusRed,
)
},
)
}
@Composable
private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
val isMuted = node.isMuted
DropdownMenuItem(
onClick = {
onMute()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
)
},
text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
)
}
@Composable
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
DropdownMenuItem(
onClick = {
onRemove()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(Res.string.remove),
color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
)
},
)
}

View file

@ -23,17 +23,12 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -52,91 +47,26 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
Text(
text = text,
modifier = Modifier.weight(weight),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
private const val WEIGHT_10 = .10f
private const val WEIGHT_15 = .15f
private const val WEIGHT_20 = .20f
private const val WEIGHT_40 = .40f
@Composable
private fun HeaderItem(compactWidth: Boolean) {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
PositionText(stringResource(Res.string.latitude), WEIGHT_20)
PositionText(stringResource(Res.string.longitude), WEIGHT_20)
PositionText(stringResource(Res.string.sats), WEIGHT_10)
PositionText(stringResource(Res.string.alt), WEIGHT_15)
if (!compactWidth) {
PositionText(stringResource(Res.string.speed), WEIGHT_15)
PositionText(stringResource(Res.string.heading), WEIGHT_15)
}
PositionText(stringResource(Res.string.timestamp), WEIGHT_40)
}
}
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}
}
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
@ -225,7 +155,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
HeaderItem(compactWidth)
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
@ -251,17 +181,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
}
}
@Composable
private fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<Position>,
displayUnits: Config.DisplayConfig.DisplayUnits,
) {
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
}
}
@Suppress("MagicNumber")
private val testPosition =
Position(

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
@ -52,7 +53,8 @@ private const val HUNDRED = 100f
private const val MILLIMETERS_PER_METER = 1000f
@Suppress("TooManyFunctions")
open class CompassViewModel(
@KoinViewModel
class CompassViewModel(
private val headingProvider: CompassHeadingProvider,
private val phoneLocationProvider: PhoneLocationProvider,
private val magneticFieldProvider: MagneticFieldProvider,
@ -72,10 +74,9 @@ open class CompassViewModel(
targetPosition = targetPos
targetPositionProto = node.position
val targetColor = Color(node.colors.second)
val targetName =
(node.user.long_name ?: "").ifBlank { (node.user.short_name ?: "").ifBlank { node.num.toString() } }
val targetName = node.user.long_name.ifBlank { node.user.short_name.ifBlank { node.num.toString() } }
targetPositionTimeSec =
node.position.timestamp?.takeIf { it > 0 }?.toLong() ?: node.position.time?.takeIf { it > 0 }?.toLong()
node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong()
_uiState.update {
it.copy(
@ -207,10 +208,10 @@ open class CompassViewModel(
val positionTime = targetPositionTimeSec
if (positionTime == null || positionTime <= 0) return null
val gpsAccuracyMm = (position.gps_accuracy ?: 0).toFloat()
val pdop = position.PDOP ?: 0
val hdop = position.HDOP ?: 0
val vdop = position.VDOP ?: 0
val gpsAccuracyMm = position.gps_accuracy.toFloat()
val pdop = position.PDOP
val hdop = position.HDOP
val vdop = position.VDOP
val dop: Float? =
when {
pdop > 0 -> pdop / HUNDRED
@ -225,7 +226,7 @@ open class CompassViewModel(
}
// Fallback: infer radius from precision bits if provided
val precisionBits = position.precision_bits ?: 0
val precisionBits = position.precision_bits
if (precisionBits > 0) {
return precisionBitsToMeters(precisionBits).toFloat()
}

View file

@ -167,7 +167,7 @@ internal fun EnvironmentMetrics(
add(
VectorMetricInfo(
label = Res.string.wind,
value = ws.toFloat().toSpeedString(displayUnits),
value = ws.toSpeedString(displayUnits),
icon = Icons.Outlined.Navigation,
rotateIcon = normalizedBearing.toFloat(),
),

View file

@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -52,7 +51,7 @@ import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.thenIf
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun InfoCard(
text: String,
@ -106,11 +105,7 @@ fun InfoCard(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
value,
style = MaterialTheme.typography.labelLargeEmphasized,
color = MaterialTheme.colorScheme.onSurface,
)
Text(value, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
}
}

View file

@ -83,7 +83,7 @@ fun LinkedCoordinatesItem(
leadingIcon = Icons.Rounded.LocationOn,
supportingText = "$ago$coordinates$elevationText",
trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(),
onClick = { openMap(node.latitude, node.longitude, node.user.long_name ?: "") },
onClick = { openMap(node.latitude, node.longitude, node.user.long_name) },
onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } },
)
}

View file

@ -0,0 +1,155 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.DoDisturbOn
import androidx.compose.material.icons.outlined.DoDisturbOn
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
/**
* Shared context menu for node actions (favorite, ignore, mute, remove).
*
* Used by both Android and Desktop adaptive node list screens.
*/
@Composable
fun NodeContextMenu(
expanded: Boolean,
node: Node,
onFavorite: () -> Unit,
onIgnore: () -> Unit,
onMute: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
FavoriteMenuItem(node, onFavorite, onDismiss)
IgnoreMenuItem(node, onIgnore, onDismiss)
if (node.capabilities.canMuteNode) {
MuteMenuItem(node, onMute, onDismiss)
}
RemoveMenuItem(node, onRemove, onDismiss)
}
}
@Composable
private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
val isFavorite = node.isFavorite
DropdownMenuItem(
onClick = {
onFavorite()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = null,
)
},
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
)
}
@Composable
private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
val isIgnored = node.isIgnored
DropdownMenuItem(
onClick = {
onIgnore()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
color = MaterialTheme.colorScheme.StatusRed,
)
},
)
}
@Composable
private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
val isMuted = node.isMuted
DropdownMenuItem(
onClick = {
onMute()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
)
},
text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
)
}
@Composable
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
DropdownMenuItem(
onClick = {
onRemove()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(Res.string.remove),
color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
)
},
)
}

View file

@ -157,7 +157,7 @@ private fun MainNodeDetails(node: Node) {
MqttAndVerificationRow(node)
}
val publicKey = node.publicKey ?: node.user.public_key
if (publicKey != null && publicKey.size > 0) {
if (publicKey.size > 0) {
SectionDivider()
PublicKeyItem(publicKey.toByteArray())
}
@ -169,13 +169,13 @@ private fun NameAndRoleRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.short_name),
value = (node.user.short_name ?: "").ifEmpty { "???" },
value = node.user.short_name.ifEmpty { "???" },
icon = MeshtasticIcons.Person,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.role),
value = node.user.role?.name ?: "",
value = node.user.role.name,
icon = MeshtasticIcons.role(node.user.role),
modifier = Modifier.weight(1f),
)
@ -235,16 +235,17 @@ private fun HearsAndHopsRow(node: Node) {
@Composable
private fun UserAndUptimeRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
val uptimeSeconds = node.deviceMetrics.uptime_seconds
InfoItem(
label = stringResource(Res.string.user_id),
value = node.user.id ?: "",
value = node.user.id,
icon = MeshtasticIcons.Person,
modifier = Modifier.weight(1f),
)
if ((node.deviceMetrics.uptime_seconds ?: 0) > 0) {
if (uptimeSeconds != null && uptimeSeconds > 0) {
InfoItem(
label = stringResource(Res.string.uptime),
value = formatUptime(node.deviceMetrics.uptime_seconds!!),
value = formatUptime(uptimeSeconds),
icon = MeshtasticIcons.ArrowCircleUp,
modifier = Modifier.weight(1f),
)

View file

@ -33,7 +33,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -95,7 +94,6 @@ private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
private const val GRID_COLUMNS = 3
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Suppress("LongMethod")
fun NodeItem(
@ -109,10 +107,10 @@ fun NodeItem(
connectionState: ConnectionState,
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
val isFavorite = thatNode.isFavorite
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system =
@ -313,9 +311,10 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
val env = node.environmentMetrics
val pax = node.paxcounter
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
items.add { PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor) }
if (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 =
if (tempInFahrenheit) {
@ -387,7 +386,6 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) {
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun NodeItemHeader(
thatNode: Node,
@ -415,15 +413,19 @@ private fun NodeItemHeader(
modifier = Modifier.size(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.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),
style = MaterialTheme.typography.titleMedium.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
modifier = Modifier.weight(1f),
)
TransportIcon(
transport = thatNode.lastTransport,

View file

@ -181,7 +181,7 @@ private fun StatusBadge(
tint: Color = LocalContentColor.current,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
state = rememberTooltipState(),
) {

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
@ -56,7 +55,6 @@ import org.meshtastic.core.resources.request_telemetry
import org.meshtastic.core.resources.telemetry
import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.LineAxis
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Refresh
@ -190,7 +188,7 @@ private fun rememberTelemetricFeatures(
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, onAction: (NodeDetailAction) -> Unit) {
@ -223,7 +221,6 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
state = rememberTooltipState(),
) {
FilledTonalIconButton(
shapes = IconButtonDefaults.shapes(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
onClick = {
feature.logsType?.let {
@ -232,9 +229,9 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
},
) {
Icon(
MeshtasticIcons.LineAxis,
imageVector = feature.logsType?.icon ?: feature.icon,
modifier = Modifier.size(24.dp),
contentDescription = logsDescription,
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
tint = MaterialTheme.colorScheme.primary,
)
}
@ -271,7 +268,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
if (showContent) {
Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
feature.content?.invoke(node)
feature.content.invoke(node)
}
}
}

View file

@ -35,20 +35,15 @@ constructor(
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestNeighborInfo ->
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestPosition ->
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestTelemetry ->
nodeRequestActions.requestTelemetry(
scope,
action.node.num,
action.node.user.long_name ?: "",
action.type,
)
nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.long_name, action.type)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name)
else -> {}
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.Node
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.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
/**
* Shared content composable for node details, usable from both Android and Desktop.
*
* Renders a [Crossfade] between a loading spinner and the full [NodeDetailList] when the node is present. This
* composable contains no Android-specific APIs overlays (compass, bottom sheets, permission launchers) are handled by
* the platform-specific screen wrapper.
*/
@Composable
fun NodeDetailContent(
uiState: NodeDetailUiState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
if (isNodePresent && uiState.node != null) {
NodeDetailList(
node = uiState.node,
ourNode = uiState.ourNode,
uiState = uiState,
listState = listState,
onAction = onAction,
onFirmwareSelect = onFirmwareSelect,
onSaveNotes = onSaveNotes,
)
} else {
val loadingDescription = stringResource(Res.string.loading)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
}
}
}
}
/**
* Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and
* administration.
*/
@Composable
fun NodeDetailList(
node: Node,
ourNode: Node?,
uiState: NodeDetailUiState,
listState: LazyListState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { NodeDetailsSection(node) }
item {
DeviceActions(
node = node,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
isLocal = uiState.metricsState.isLocal,
)
}
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
if (!uiState.metricsState.isManaged) {
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
}
}
}

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
@ -58,7 +59,8 @@ data class NodeDetailUiState(
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
open class NodeDetailViewModel(
@KoinViewModel
class NodeDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
@ -98,24 +100,20 @@ open class NodeDetailViewModel(
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestNeighborInfo ->
nodeRequestActions.requestNeighborInfo(
viewModelScope,
action.node.num,
action.node.user.long_name ?: "",
)
nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestPosition ->
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name)
is NodeMenuAction.RequestTelemetry ->
nodeRequestActions.requestTelemetry(
viewModelScope,
action.node.num,
action.node.user.long_name ?: "",
action.node.user.long_name,
action.type,
)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name ?: "")
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name)
else -> {}
}
}

View file

@ -70,10 +70,7 @@ constructor(
fun requestIgnoreNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.long_name ?: "",
)
getString(if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add, node.user.long_name)
alertManager.showAlert(
titleRes = Res.string.ignore,
message = message,
@ -89,7 +86,7 @@ constructor(
fun requestMuteNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name ?: "")
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name)
alertManager.showAlert(
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
message = message,
@ -107,7 +104,7 @@ constructor(
val message =
getString(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.long_name ?: "",
node.user.long_name,
)
alertManager.showAlert(
titleRes = Res.string.favorite,

View file

@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
@ -200,7 +200,7 @@ constructor(
@Suppress("MagicNumber")
val nodeName =
node.user.long_name?.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
@ -42,7 +43,8 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
@Suppress("LongParameterList")
open class NodeListViewModel(
@KoinViewModel
class NodeListViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,

View file

@ -28,7 +28,6 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -37,9 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -51,34 +47,23 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import java.text.DateFormat
import kotlin.time.Duration.Companion.days
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
const val SCROLL_BIAS = 0.5f
@ -101,23 +86,25 @@ object CommonCharts {
/** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */
val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ ->
val date = (value * MS_PER_SEC.toDouble()).toLong().toInstant().toDate()
val timestampMillis = (value * MS_PER_SEC.toDouble()).toLong()
val xLength = context.ranges.xLength
val zoom = if (context is CartesianDrawingContext) context.zoom else 1f
val visibleSpan = xLength / zoom
when {
visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible
visibleSpan <= 2.days.inWholeSeconds -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible
visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> DateFormatter.formatTimeWithSeconds(timestampMillis)
visibleSpan <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis)
visibleSpan <= 14.days.inWholeSeconds -> {
// < 2 weeks visible: separate date and time with a newline
val dateStr = DATE_FORMAT.format(date)
val timeStr = TIME_MINUTE_FORMAT.format(date)
val dateStr = DateFormatter.formatDate(timestampMillis)
val timeStr = DateFormatter.formatTime(timestampMillis)
"$dateStr\n$timeStr"
}
else -> DATE_FORMAT.format(date)
else -> DateFormatter.formatDate(timestampMillis)
}
}
fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis)
}
data class LegendData(
@ -221,58 +208,7 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
}
@Composable
fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier =
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Preview
@Suppress("UnusedPrivateMember") // Compose preview
@Composable
private fun LegendPreview() {
val data =

View file

@ -49,7 +49,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
@ -81,7 +80,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@ -126,7 +124,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.deviceMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
val data = state.deviceMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
val snackbarHostState = remember { SnackbarHostState() }
val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } }
@ -188,7 +186,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
titleRes = Res.string.device_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.time ?: 0).toDouble() },
timeProvider = { it.time.toDouble() },
infoData = infoItems,
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) },
@ -215,8 +213,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
itemsIndexed(data) { _, telemetry ->
DeviceMetricsCard(
telemetry = telemetry,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
isSelected = telemetry.time.toDouble() == selectedX,
onClick = { onCardClick(telemetry.time.toDouble()) },
)
}
}
@ -290,19 +288,19 @@ private fun DeviceMetricsChart(
lineSeries {
if (batteryData.isNotEmpty()) {
series(
x = batteryData.map { it.time ?: 0 },
x = batteryData.map { it.time },
y = batteryData.map { (it.device_metrics?.battery_level ?: 0).toFloat() },
)
}
if (chUtilData.isNotEmpty()) {
series(
x = chUtilData.map { it.time ?: 0 },
x = chUtilData.map { it.time },
y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f },
)
}
if (airUtilData.isNotEmpty()) {
series(
x = airUtilData.map { it.time ?: 0 },
x = airUtilData.map { it.time },
y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f },
)
}
@ -312,7 +310,7 @@ private fun DeviceMetricsChart(
if (voltageData.isNotEmpty()) {
lineSeries {
series(
x = voltageData.map { it.time ?: 0 },
x = voltageData.map { it.time },
y = voltageData.map { it.device_metrics?.voltage ?: 0f },
)
}
@ -389,8 +387,7 @@ private fun DeviceMetricsChart(
}
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@Composable
private fun DeviceMetricsChartPreview() {
val now = nowSeconds.toInt()
@ -424,7 +421,7 @@ private fun DeviceMetricsChartPreview() {
@Suppress("LongMethod")
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.device_metrics
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
val time = telemetry.time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -444,7 +441,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@ -505,8 +502,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
}
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@Composable
private fun DeviceMetricsCardPreview() {
val now = nowSeconds.toInt()
@ -525,8 +521,7 @@ private fun DeviceMetricsCardPreview() {
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@Composable
private fun DeviceMetricsScreenPreview() {
val now = nowSeconds.toInt()

View file

@ -14,6 +14,8 @@
* 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")
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
@ -44,7 +46,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
@ -67,7 +68,6 @@ import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@ -97,7 +97,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
titleRes = Res.string.env_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = filteredTelemetries,
timeProvider = { (it.time ?: 0).toDouble() },
timeProvider = { it.time.toDouble() },
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
@ -125,8 +125,8 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
isSelected = telemetry.time.toDouble() == selectedX,
onClick = { onCardClick(telemetry.time.toDouble()) },
)
}
}
@ -386,12 +386,12 @@ private fun EnvironmentMetricsCard(
@Composable
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
val time = telemetry.time.toLong() * MS_PER_SEC
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@ -413,8 +413,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
}
}
@Suppress("MagicNumber") // preview data
@Preview(showBackground = true)
@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@Composable
private fun PreviewEnvironmentMetricsContent() {
val fakeEnvMetrics =

View file

@ -51,12 +51,12 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disk_free_indexed
@ -68,12 +68,8 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.DataArray
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.Telemetry
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -127,7 +123,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () ->
@Composable
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
val hostMetrics = telemetry.host_metrics
val time = telemetry.time.toLong() * CommonCharts.MS_PER_SEC
val time = telemetry.time.toLong() * TimeConstants.MS_PER_SEC
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
@ -140,7 +136,7 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
text = DATE_TIME_FORMAT.format(time),
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@ -247,39 +243,31 @@ const val BYTES_IN_KB = 1024.0
const val BYTES_IN_MB = BYTES_IN_KB * 1024.0
const val BYTES_IN_GB = BYTES_IN_MB * 1024.0
private const val DECIMAL_FACTOR_1 = 10.0
private const val DECIMAL_FACTOR_2 = 100.0
fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
val formatter =
DecimalFormat().apply {
maximumFractionDigits = decimalPlaces
minimumFractionDigits = 0
isGroupingUsed = false
fun formatValue(value: Double): String {
// Simple decimal formatting without java.text.DecimalFormat
val factor =
when (decimalPlaces) {
0 -> 1.0
1 -> DECIMAL_FACTOR_1
else -> DECIMAL_FACTOR_2
}
val rounded = kotlin.math.round(value * factor) / factor
return if (rounded == rounded.toLong().toDouble()) {
rounded.toLong().toString()
} else {
rounded.toString()
}
}
return when {
bytes < 0 -> "N/A" // Handle negative bytes gracefully
bytes < 0 -> "N/A"
bytes == 0L -> "0 B"
bytes >= BYTES_IN_GB -> "${formatter.format(bytes / BYTES_IN_GB)} GB"
bytes >= BYTES_IN_MB -> "${formatter.format(bytes / BYTES_IN_MB)} MB"
bytes >= BYTES_IN_KB -> "${formatter.format(bytes / BYTES_IN_KB)} KB"
bytes >= BYTES_IN_GB -> "${formatValue(bytes / BYTES_IN_GB)} GB"
bytes >= BYTES_IN_MB -> "${formatValue(bytes / BYTES_IN_MB)} MB"
bytes >= BYTES_IN_KB -> "${formatValue(bytes / BYTES_IN_KB)} KB"
else -> "$bytes B"
}
}
@Suppress("MagicNumber")
@PreviewLightDark
@Composable
private fun HostMetricsItemPreview() {
val hostMetrics =
HostMetrics(
uptime_seconds = 3600,
freemem_bytes = 2048000,
diskfree1_bytes = 104857600,
diskfree2_bytes = 2097915200,
diskfree3_bytes = 44444,
load1 = 30,
load5 = 75,
load15 = 19,
user_string = "test",
)
val logs = Telemetry(time = nowSeconds.toInt(), host_metrics = hostMetrics)
AppTheme { HostMetricsItem(telemetry = logs) }
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delete
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
/** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */
@Composable
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier =
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
},
)
}

View file

@ -36,10 +36,12 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
@ -67,9 +69,10 @@ import org.meshtastic.proto.Paxcount as ProtoPaxcount
/**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
open class MetricsViewModel(
val destNum: Int,
@InjectedParam val destNum: Int,
protected val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val serviceRepository: ServiceRepository,

View file

@ -75,8 +75,7 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM
}
}
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow

View file

@ -53,9 +53,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
@ -71,7 +70,6 @@ import org.meshtastic.core.ui.icon.Paxcount
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import java.text.DateFormat
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
@ -180,8 +178,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
val availableTimeFrames by metricsViewModel.availableTimeFrames.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val dateFormat = DateFormat.getDateTimeInstance()
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
@ -199,7 +195,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
paxMetrics
.map {
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
}
@ -254,7 +250,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
PaxMetricsItem(
log = log,
pax = pax,
dateFormat = dateFormat,
isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
)
@ -281,7 +276,7 @@ fun PaxcountInfo(
}
@Composable
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isSelected: Boolean, onClick: () -> Unit) {
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -297,7 +292,7 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isS
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = dateFormat.format(log.received_date.toInstant().toDate()),
text = DateFormatter.formatDateTime(log.received_date),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
@ -310,19 +305,19 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isS
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
MetricIndicator(PaxSeries.PAX.color)
Spacer(Modifier.width(4.dp))
Text(text = "PAX: ${(pax.ble ?: 0) + (pax.wifi ?: 0)}", style = MaterialTheme.typography.bodyLarge)
Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.BLE.color)
Spacer(Modifier.width(4.dp))
Text(text = "B:${pax.ble ?: 0}", style = MaterialTheme.typography.bodyLarge)
Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.WIFI.color)
Spacer(Modifier.width(4.dp))
Text(text = "W:${pax.wifi ?: 0}", style = MaterialTheme.typography.bodyLarge)
Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
}
Text(
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime ?: 0),
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
)

View file

@ -0,0 +1,110 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
Text(
text = text,
modifier = Modifier.weight(weight),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
private const val WEIGHT_10 = .10f
private const val WEIGHT_15 = .15f
private const val WEIGHT_20 = .20f
private const val WEIGHT_40 = .40f
@Composable
fun PositionLogHeader(compactWidth: Boolean) {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
PositionText(stringResource(Res.string.latitude), WEIGHT_20)
PositionText(stringResource(Res.string.longitude), WEIGHT_20)
PositionText(stringResource(Res.string.sats), WEIGHT_10)
PositionText(stringResource(Res.string.alt), WEIGHT_15)
if (!compactWidth) {
PositionText(stringResource(Res.string.speed), WEIGHT_15)
PositionText(stringResource(Res.string.heading), WEIGHT_15)
}
PositionText(stringResource(Res.string.timestamp), WEIGHT_40)
}
}
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}
}
@Composable
fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<Position>,
displayUnits: Config.DisplayConfig.DisplayUnits,
) {
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
}
}

View file

@ -73,7 +73,6 @@ import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@ -110,7 +109,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.powerMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val snackbarHostState = remember { SnackbarHostState() }
@ -131,7 +130,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
titleRes = Res.string.power_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.time ?: 0).toDouble() },
timeProvider = { it.time.toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) },
controlPart = {
@ -172,8 +171,8 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
itemsIndexed(data) { _, telemetry ->
PowerMetricsCard(
telemetry = telemetry,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
isSelected = telemetry.time.toDouble() == selectedX,
onClick = { onCardClick(telemetry.time.toDouble()) },
)
}
}
@ -223,7 +222,7 @@ private fun PowerMetricsChart(
if (currentData.isNotEmpty()) {
lineSeries {
series(
x = currentData.map { it.time ?: 0 },
x = currentData.map { it.time },
y = currentData.map { retrieveCurrent(selectedChannel, it) },
)
}
@ -231,7 +230,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
lineSeries {
series(
x = voltageData.map { it.time ?: 0 },
x = voltageData.map { it.time },
y = voltageData.map { retrieveVoltage(selectedChannel, it) },
)
}
@ -311,7 +310,7 @@ private fun PowerMetricsChart(
@Composable
@Suppress("CyclomaticComplexMethod")
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
val time = telemetry.time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -332,7 +331,7 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
/* Time */
Row {
Text(
text = DATE_TIME_FORMAT.format(time),
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)

View file

@ -67,7 +67,6 @@ import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
@ -88,7 +87,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.signalMetrics.filter { (it.rx_time ?: 0).toLong() >= timeFrame.timeThreshold() }
val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
@ -108,7 +107,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
titleRes = Res.string.signal_quality,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.rx_time ?: 0).toDouble() },
timeProvider = { it.rx_time.toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
infoData =
@ -138,8 +137,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
itemsIndexed(data) { _, meshPacket ->
SignalMetricsCard(
meshPacket = meshPacket,
isSelected = (meshPacket.rx_time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((meshPacket.rx_time ?: 0).toDouble()) },
isSelected = meshPacket.rx_time.toDouble() == selectedX,
onClick = { onCardClick(meshPacket.rx_time.toDouble()) },
)
}
}
@ -163,17 +162,17 @@ private fun SignalMetricsChart(
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
val rssiData = remember(meshPackets) { meshPackets.filter { (it.rx_rssi ?: 0) != 0 } }
val snrData = remember(meshPackets) { meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) } }
val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != 0 } }
val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
LaunchedEffect(rssiData, snrData) {
modelProducer.runTransaction {
if (rssiData.isNotEmpty()) {
/* Use separate lineSeries calls to associate them with different vertical axes */
lineSeries { series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 }) }
lineSeries { series(x = rssiData.map { it.rx_time }, y = rssiData.map { it.rx_rssi }) }
}
if (snrData.isNotEmpty()) {
lineSeries { series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f }) }
lineSeries { series(x = snrData.map { it.rx_time }, y = snrData.map { it.rx_snr }) }
}
}
}
@ -261,7 +260,7 @@ private fun SignalMetricsChart(
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = (meshPacket.rx_time ?: 0).toLong() * MS_PER_SEC
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -284,7 +283,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@ -297,14 +296,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.0f dBm".format((meshPacket.rx_rssi ?: 0).toFloat()),
text = "%.0f dBm".format(meshPacket.rx_rssi.toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1f dB".format(meshPacket.rx_snr ?: 0f),
text = "%.1f dB".format(meshPacket.rx_snr),
style = MaterialTheme.typography.labelLarge,
)
}
@ -313,7 +312,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr ?: 0f, meshPacket.rx_rssi ?: 0)
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
}
}
}

View file

@ -40,15 +40,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.traceroute
@ -66,7 +65,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@ -74,7 +72,6 @@ import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.RouteDiscovery
@OptIn(ExperimentalFoundationApi::class)
@ -100,8 +97,7 @@ fun TracerouteLogScreen(
}
}
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
@ -265,16 +261,3 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route
}
}
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {
val time = DateFormatter.formatDateTime(nowMillis)
AppTheme {
MetricLogItem(
icon = MeshtasticIcons.Group,
text = "$time - Direct",
contentDescription = stringResource(Res.string.traceroute),
)
}
}

View file

@ -20,4 +20,4 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
val Node.isEffectivelyUnmessageable: Boolean
get() = user.is_unmessagable ?: (user.role?.isUnmessageableRole() == true)
get() = user.is_unmessagable ?: user.role.isUnmessageableRole()

View file

@ -17,8 +17,8 @@
package org.meshtastic.feature.node.model
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Config
import org.meshtastic.proto.FirmwareEdition
@ -68,13 +68,13 @@ data class MetricsState(
/** Finds the oldest timestamp (in seconds) among all collected metric types. */
@Suppress("MagicNumber")
fun oldestTimestampSeconds(): Long? {
val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).mapNotNull { it.time?.toLong() }
val signalTimes = signalMetrics.mapNotNull { it.rx_time?.toLong() }
val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).map { it.time.toLong() }
val signalTimes = signalMetrics.map { it.rx_time.toLong() }
val logTimes =
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
it.received_date / 1000L
}
val positionTimes = positionLogs.mapNotNull { it.time?.toLong() }
val positionTimes = positionLogs.map { it.time.toLong() }
val allTimes = telemetryTimes + signalTimes + logTimes + positionTimes
return allTimes.minOrNull()

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Error handling tests for node feature.
*
* Tests edge cases, failure recovery, and boundary conditions.
*/
class NodeErrorHandlingTest {
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
@BeforeTest
fun setUp() {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
}
@Test
fun testGetNonexistentNode() = runTest {
val node = nodeRepository.getNode("!nonexistent")
// FakeNodeRepository returns a fallback node (never null)
assertEquals("!nonexistent", node.user.id)
}
@Test
fun testDeleteNonexistentNode() = runTest {
val beforeCount = nodeRepository.nodeDBbyNum.value.size
nodeRepository.deleteNode(999)
val afterCount = nodeRepository.nodeDBbyNum.value.size
assertEquals(beforeCount, afterCount)
}
@Test
fun testNodeDatabaseEmptyOnStart() = runTest {
val nodes = nodeRepository.nodeDBbyNum.value
assertEquals(0, nodes.size)
}
@Test
fun testRepeatedClear() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(5))
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
// Clear multiple times
nodeRepository.clearNodeDB(preserveFavorites = false)
nodeRepository.clearNodeDB(preserveFavorites = false)
nodeRepository.clearNodeDB(preserveFavorites = false)
// Should still be empty
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testSetEmptyNodeList() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
// Set to empty
nodeRepository.setNodes(emptyList())
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testDeleteAllNodes() = runTest {
val nodes = TestDataFactory.createTestNodes(5)
nodeRepository.setNodes(nodes)
// Delete each node
nodes.forEach { node -> nodeRepository.deleteNode(node.num) }
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testNodeMetadataOnDeletedNode() = runTest {
val node = TestDataFactory.createTestNode(num = 1, longName = "Test")
nodeRepository.setNodes(listOf(node))
// Delete node
nodeRepository.deleteNode(1)
// Try to get notes on deleted node
// Should not crash
assertTrue(true)
}
@Test
fun testNotesOnNonexistentNode() = runTest {
// Set notes on node that never existed
nodeRepository.setNodeNotes(999, "Notes")
// Should be no-op
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testConnectionStateChangesDuringNodeManagement() = runTest {
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected)
// Add nodes while disconnected (local operation)
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
// Switch to connected
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Connected)
// Nodes should still be there
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
// Switch back to disconnected
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected)
// Nodes still there
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testLargeNodeDatabaseHandling() = runTest {
// Create large dataset
val largeNodeSet = TestDataFactory.createTestNodes(500)
nodeRepository.setNodes(largeNodeSet)
assertEquals(500, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testRapidAddDelete() = runTest {
// Rapidly add and delete nodes
repeat(10) { iteration ->
nodeRepository.setNodes(TestDataFactory.createTestNodes(5))
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
nodeRepository.clearNodeDB(preserveFavorites = false)
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
// Final state should be clean
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
}

View file

@ -0,0 +1,179 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Integration tests for node feature.
*
* Tests node filtering, sorting, and state management with multiple nodes.
*/
class NodeIntegrationTest {
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
@BeforeTest
fun setUp() {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
}
@Test
fun testPopulatingMeshWithMultipleNodes() = runTest {
// Create diverse node set
val nodes =
listOf(
TestDataFactory.createTestNode(num = 1, longName = "Alice", shortName = "A"),
TestDataFactory.createTestNode(num = 2, longName = "Bob", shortName = "B"),
TestDataFactory.createTestNode(num = 3, longName = "Charlie", shortName = "C"),
TestDataFactory.createTestNode(num = 4, longName = "Diana", shortName = "D"),
TestDataFactory.createTestNode(num = 5, longName = "Eve", shortName = "E"),
)
// Add to repository
nodeRepository.setNodes(nodes)
// Verify all nodes present
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1))
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(5))
}
@Test
fun testRetrievingNodeByUserId() = runTest {
val node = TestDataFactory.createTestNode(num = 42, userId = "!alice123", longName = "Alice")
nodeRepository.setNodes(listOf(node))
// Retrieve by userId
val retrieved = nodeRepository.getNode("!alice123")
assertEquals("Alice", retrieved.user.long_name)
assertEquals(42, retrieved.num)
}
@Test
fun testNodeDeletionAndRemoval() = runTest {
val nodes = TestDataFactory.createTestNodes(5)
nodeRepository.setNodes(nodes)
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
// Delete one node
nodeRepository.deleteNode(2)
// Verify deletion
assertEquals(4, nodeRepository.nodeDBbyNum.value.size)
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(2))
}
@Test
fun testBulkNodeDeletion() = runTest {
val nodes = TestDataFactory.createTestNodes(10)
nodeRepository.setNodes(nodes)
assertEquals(10, nodeRepository.nodeDBbyNum.value.size)
// Delete multiple nodes
nodeRepository.deleteNodes(listOf(1, 3, 5, 7, 9))
// Verify deletions
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(1))
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(3))
}
@Test
fun testUpdatingNodeMetadata() = runTest {
val originalNode = TestDataFactory.createTestNode(num = 1, longName = "Original Name")
nodeRepository.setNodes(listOf(originalNode))
// Update node notes
nodeRepository.setNodeNotes(1, "Test notes")
// Retrieve and verify
val updated = nodeRepository.getUser(1)
assertTrue(true, "Node updated successfully")
}
@Test
fun testNodeConnectionStateTracking() = runTest {
// Create nodes with different last heard times
val onlineNode =
TestDataFactory.createTestNode(num = 1, lastHeard = (System.currentTimeMillis() / 1000).toInt())
val offlineNode =
TestDataFactory.createTestNode(
num = 2,
lastHeard = ((System.currentTimeMillis() / 1000) - 86400).toInt(), // 24 hours ago
)
nodeRepository.setNodes(listOf(onlineNode, offlineNode))
// Verify both nodes exist
assertEquals(2, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testFilteringNodesBySearchTerm() = runTest {
val nodes =
listOf(
TestDataFactory.createTestNode(num = 1, longName = "Alice Wonderland", shortName = "AW"),
TestDataFactory.createTestNode(num = 2, longName = "Bob Builder", shortName = "BB"),
TestDataFactory.createTestNode(num = 3, longName = "Charlie Chaplin", shortName = "CC"),
)
nodeRepository.setNodes(nodes)
// Manual filtering for test
val allNodes = nodeRepository.nodeDBbyNum.value.values.toList()
val filtered = allNodes.filter { it.user.long_name.contains("Alice", ignoreCase = true) }
assertEquals(1, filtered.size)
assertEquals("Alice Wonderland", filtered.first().user.long_name)
}
@Test
fun testMaintainingFavoriteNodesList() = runTest {
val node1 = TestDataFactory.createTestNode(num = 1, longName = "Favorite Node")
val node2 = TestDataFactory.createTestNode(num = 2, longName = "Regular Node")
// Add nodes
nodeRepository.setNodes(listOf(node1, node2))
// In real implementation, would have separate favorite tracking
// For now, verify nodes are accessible
assertEquals(2, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testClearingAllNodesFromMesh() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(10))
assertEquals(10, nodeRepository.nodeDBbyNum.value.size)
// Clear database
nodeRepository.clearNodeDB(preserveFavorites = false)
// Verify cleared
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Bootstrap tests for NodeListViewModel.
*
* Demonstrates using FakeNodeRepository with a node list feature.
*/
class NodeListViewModelTest {
private lateinit var viewModel: NodeListViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var radioConfigRepository: RadioConfigRepository
private lateinit var serviceRepository: ServiceRepository
private lateinit var nodeFilterPreferences: NodeFilterPreferences
private lateinit var nodeManagementActions: NodeManagementActions
private lateinit var getFilteredNodesUseCase: GetFilteredNodesUseCase
@BeforeTest
fun setUp() {
// Use real fakes
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
// Mock remaining dependencies with explicit types
radioConfigRepository = mockk(relaxed = true)
serviceRepository = mockk(relaxed = true)
nodeFilterPreferences =
mockk(relaxed = true) {
every { nodeSortOption } returns MutableStateFlow(org.meshtastic.core.model.NodeSortOption.LAST_HEARD)
every { includeUnknown } returns MutableStateFlow(true)
every { excludeInfrastructure } returns MutableStateFlow(false)
every { onlyOnline } returns MutableStateFlow(false)
}
nodeManagementActions = mockk(relaxed = true)
@Suppress("UNCHECKED_CAST")
getFilteredNodesUseCase = mockk<GetFilteredNodesUseCase>(relaxed = true)
viewModel =
NodeListViewModel(
savedStateHandle = SavedStateHandle(),
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
radioController = radioController,
nodeManagementActions = nodeManagementActions,
getFilteredNodesUseCase = getFilteredNodesUseCase,
nodeFilterPreferences = nodeFilterPreferences,
)
}
@Test
fun testInitialization() = runTest {
setUp()
// ViewModel should initialize without errors
assertTrue(true, "NodeListViewModel initialized successfully")
}
@Test
fun testOurNodeInfoFlow() = runTest {
setUp()
// Verify ourNodeInfo StateFlow is accessible
val ourNode = viewModel.ourNodeInfo.value
assertTrue(ourNode == null, "ourNodeInfo starts as null before connection")
}
@Test
fun testNodeCounts() = runTest {
setUp()
// Add test nodes to repository
val testNodes = TestDataFactory.createTestNodes(3)
nodeRepository.setNodes(testNodes)
// Verify nodes are in repository
assertEquals(3, nodeRepository.nodeDBbyNum.value.size, "Test nodes added to repository")
}
@Test
fun testTotalAndOnlineNodeCounts() = runTest {
setUp()
// Verify count flows are accessible
val totalCount = viewModel.totalNodeCount.value
val onlineCount = viewModel.onlineNodeCount.value
// Both should be accessible without error
assertTrue(true, "Node count flows are accessible")
}
}