feat(navigation): Implement adaptive list-detail for contacts and nodes (#3850)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-11-28 20:05:07 -06:00 committed by GitHub
parent d60e84fa4d
commit 78274c7923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 630 additions and 222 deletions

View file

@ -219,6 +219,8 @@ dependencies {
implementation(projects.feature.firmware)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.navigationSuite)
implementation(libs.material)
implementation(libs.androidx.compose.material3)

View file

@ -23,15 +23,12 @@ import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.contact.ContactsScreen
import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.QuickChatScreen
@Suppress("LongMethod")
@ -40,18 +37,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
) {
ContactsScreen(
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
)
AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
}
composable<ContactsRoutes.Messages>(
deepLinks =
@ -63,13 +49,11 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
MessageScreen(
contactKey = args.contactKey,
message = args.message,
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
onNavigateBack = navController::navigateUp,
AdaptiveContactsScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
initialContactKey = args.contactKey,
initialMessage = args.message,
)
}
}

View file

@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
@ -38,6 +37,8 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.ContactsRoutes
@ -45,7 +46,6 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.environment
@ -58,8 +58,6 @@ import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
@ -69,23 +67,27 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import kotlin.reflect.KClass
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeListScreen(
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
AdaptiveNodeListScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
nodeDetailGraph(navController)
nodeDetailGraph(navController, scrollToTopEvents)
}
}
@Suppress("LongMethod")
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
// We keep this route for deep linking or direct navigation to details,
// but typically users will navigate via the Adaptive screen in NodesRoutes.Nodes
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail>(
deepLinks =
@ -95,13 +97,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
),
),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
NodeDetailScreen(
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
onNavigate = { navController.navigate(it) },
onNavigateUp = { navController.navigateUp() },
viewModel = hiltViewModel(parentEntry),
val args = backStackEntry.toRoute<NodesRoutes.NodeDetail>()
// When navigating directly to NodeDetail (e.g. from Map or deep link),
// we use the Adaptive screen initialized with the specific node ID.
AdaptiveNodeListScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
@ -114,88 +117,98 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
NodeMapScreen(
hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry),
onNavigateUp = navController::navigateUp,
)
val vm = hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry)
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.route) {
is NodeDetailRoutes.DeviceMetrics ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PositionLog ->
) {
it.destNum
}
NodeDetailRoutes.PositionLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.EnvironmentMetrics ->
) {
it.destNum
}
NodeDetailRoutes.EnvironmentMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.SignalMetrics ->
) {
it.destNum
}
NodeDetailRoutes.SignalMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PowerMetrics ->
) {
it.destNum
}
NodeDetailRoutes.PowerMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.TracerouteLog ->
) {
it.destNum
}
NodeDetailRoutes.TracerouteLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.HostMetricsLog ->
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PaxMetrics ->
) {
it.destNum
}
NodeDetailRoutes.PaxMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
navController,
entry,
entry.screenComposable,
)
) {
it.destNum
}
else -> Unit
}
}
}
}
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.route::class) }
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
/**
* Helper to define a composable route for a screen within the node detail graph.
*
* This function simplifies adding screens by handling common tasks like:
* - Setting up deep links based on the [NodeDetailRoute] definition.
* - Retrieving the parent [NavBackStackEntry] for the [NodesRoutes.NodeDetailGraph].
* - Providing the [MetricsViewModel] scoped to the parent graph.
*
* @param R The type of the [Route] object, must be serializable.
* @param navController The [NavHostController] for navigation.
* @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
* @param screenContent A lambda that defines the composable content for the screen. It receives the shared
* [MetricsViewModel].
* @param screenContent A lambda that defines the composable content for the screen.
* @param getDestNum A lambda to extract the destination number from the route arguments.
*/
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
crossinline getDestNum: (R) -> Int,
) {
composable<R>(
deepLinks =
@ -207,61 +220,66 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<R>()
val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum)
screenContent(metricsViewModel, navController::navigateUp)
}
}
enum class NodeDetailRoute(
val title: StringResource,
val route: Route,
val routeClass: KClass<out Route>,
val icon: ImageVector?,
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
) {
DEVICE(
Res.string.device,
NodeDetailRoutes.DeviceMetrics,
NodeDetailRoutes.DeviceMetrics::class,
Icons.Default.Router,
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
),
POSITION_LOG(
Res.string.position_log,
NodeDetailRoutes.PositionLog,
NodeDetailRoutes.PositionLog::class,
Icons.Default.LocationOn,
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
),
ENVIRONMENT(
Res.string.environment,
NodeDetailRoutes.EnvironmentMetrics,
NodeDetailRoutes.EnvironmentMetrics::class,
Icons.Default.LightMode,
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
),
SIGNAL(
Res.string.signal,
NodeDetailRoutes.SignalMetrics,
NodeDetailRoutes.SignalMetrics::class,
Icons.Default.CellTower,
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
),
TRACEROUTE(
Res.string.traceroute,
NodeDetailRoutes.TracerouteLog,
NodeDetailRoutes.TracerouteLog::class,
Icons.Default.PermScanWifi,
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
POWER(
Res.string.power,
NodeDetailRoutes.PowerMetrics,
NodeDetailRoutes.PowerMetrics::class,
Icons.Default.Power,
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
),
HOST(
Res.string.host,
NodeDetailRoutes.HostMetricsLog,
NodeDetailRoutes.HostMetricsLog::class,
Icons.Default.Memory,
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
),
PAX(
Res.string.pax,
NodeDetailRoutes.PaxMetrics,
NodeDetailRoutes.PaxMetrics::class,
Icons.Default.People,
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),

View file

@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.contact
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
BackHandler(navigator.canNavigateBack()) { scope.launch { navigator.navigateBack() } }
LaunchedEffect(initialContactKey) {
if (initialContactKey != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ContactsScreen(
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToMessages = { contactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
},
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
activeContactKey = navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
val contactKey = navigator.currentDestination?.contentKey
if (contactKey != null) {
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
navigateToMessages = { newContactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, newContactKey) }
},
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
onNavigateBack = {
if (navigator.canNavigateBack()) {
scope.launch { navigator.navigateBack() }
} else {
navController.navigateUp()
}
},
)
} else {
PlaceholderScreen()
}
}
},
)
}
@Composable
private fun PlaceholderScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(
imageVector = MeshtasticIcons.Conversations,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.conversations),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.twotone.VolumeOff
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -67,20 +68,39 @@ fun ContactItem(
contact: Contact,
selected: Boolean,
modifier: Modifier = Modifier,
isActive: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onNodeChipClick: () -> Unit = {},
channels: AppOnlyProtos.ChannelSet? = null,
) = with(contact) {
val isOutlined = !selected && !isActive
val colors =
if (isOutlined) {
CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
} else {
val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
CardDefaults.cardColors(containerColor = containerColor)
}
val border =
if (isOutlined) {
CardDefaults.outlinedCardBorder()
} else {
null
}
Card(
modifier =
modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics { contentDescription = shortName },
shape = RoundedCornerShape(12.dp),
colors = colors,
border = border,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)

View file

@ -112,6 +112,7 @@ fun ContactsScreen(
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeContactKey: String? = null,
) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
@ -252,6 +253,7 @@ fun ContactsScreen(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
activeContactKey = activeContactKey,
onClick = onContactClick,
onLongClick = onContactLongClick,
onNodeChipClick = onNodeChipClick,
@ -473,6 +475,7 @@ private fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@ -493,6 +496,7 @@ private fun ContactListViewPaged(
contacts = contacts,
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@ -508,6 +512,7 @@ private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@ -520,6 +525,7 @@ private fun ContactListContentInternal(
contactListPlaceholdersItems(
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@ -530,6 +536,7 @@ private fun ContactListContentInternal(
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@ -544,6 +551,7 @@ private fun ContactListContentInternal(
private fun LazyListScope.contactListPlaceholdersItems(
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@ -556,10 +564,12 @@ private fun LazyListScope.contactListPlaceholdersItems(
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
val isActive = remember(placeholder.contactKey, activeContactKey) { placeholder.contactKey == activeContactKey }
ContactItem(
contact = placeholder,
selected = selected,
isActive = isActive,
onClick = { onClick(placeholder) },
onLongClick = {
onLongClick(placeholder)
@ -574,6 +584,7 @@ private fun LazyListScope.contactListPlaceholdersItems(
private fun LazyListScope.contactListPagedItems(
contacts: LazyPagingItems<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@ -590,10 +601,12 @@ private fun LazyListScope.contactListPagedItems(
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
val isActive = remember(contact.contactKey, activeContactKey) { contact.contactKey == activeContactKey }
ContactItem(
contact = contact,
selected = selected,
isActive = isActive,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(contact)

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.list.NodeListScreen
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveNodeListScreen(
navController: NavHostController,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigateToMessages: (String) -> Unit = {},
) {
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
val isDetailActive = navigator.currentDestination?.contentKey != null
BackHandler(enabled = isDetailActive) {
scope.launch {
if (navigator.canNavigateBack()) {
navigator.navigateBack()
} else {
navigator.navigateTo(ListDetailPaneScaffoldRole.List, null)
}
}
}
LaunchedEffect(initialNodeId) {
if (initialNodeId != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialNodeId)
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
NodeListScreen(
navigateToNodeDetails = { nodeId ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
},
scrollToTopEvents = scrollToTopEvents,
activeNodeId = navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
val nodeId = navigator.currentDestination?.contentKey
if (nodeId != null) {
NodeDetailScreenWrapper(
nodeId = nodeId,
navigateToMessages = onNavigateToMessages,
onNavigate = { route -> navController.navigate(route) },
onNavigateUp = { scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.List, null) } },
)
} else {
PlaceholderScreen()
}
}
},
)
}
@Composable
private fun NodeDetailScreenWrapper(
nodeId: Int,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
) {
NodeDetailScreen(
onNavigateUp = onNavigateUp,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
overrideNodeId = nodeId,
)
}
@Composable
private fun PlaceholderScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(
imageVector = MeshtasticIcons.Nodes,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.nodes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -64,23 +64,23 @@ object NodesRoutes {
}
object NodeDetailRoutes {
@Serializable data object DeviceMetrics : Route
@Serializable data class DeviceMetrics(val destNum: Int) : Route
@Serializable data object NodeMap : Route
@Serializable data class NodeMap(val destNum: Int) : Route
@Serializable data object PositionLog : Route
@Serializable data class PositionLog(val destNum: Int) : Route
@Serializable data object EnvironmentMetrics : Route
@Serializable data class EnvironmentMetrics(val destNum: Int) : Route
@Serializable data object SignalMetrics : Route
@Serializable data class SignalMetrics(val destNum: Int) : Route
@Serializable data object PowerMetrics : Route
@Serializable data class PowerMetrics(val destNum: Int) : Route
@Serializable data object TracerouteLog : Route
@Serializable data class TracerouteLog(val destNum: Int) : Route
@Serializable data object HostMetricsLog : Route
@Serializable data class HostMetricsLog(val destNum: Int) : Route
@Serializable data object PaxMetrics : Route
@Serializable data class PaxMetrics(val destNum: Int) : Route
}
object SettingsRoutes {

View file

@ -18,6 +18,7 @@
package org.meshtastic.feature.messaging
import android.os.RemoteException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
@ -54,10 +55,12 @@ import javax.inject.Inject
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MessageViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
@ -92,12 +95,21 @@ constructor(
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
.cachedIn(viewModelScope)
init {
val contactKey = savedStateHandle.get<String>("contactKey")
if (contactKey != null) {
contactKeyForPagedMessages.value = contactKey
}
}
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
fun getMessagesFromPaged(contactKey: String): Flow<PagingData<Message>> {
contactKeyForPagedMessages.value = contactKey
if (contactKeyForPagedMessages.value != contactKey) {
contactKeyForPagedMessages.value = contactKey
}
return pagedMessagesForContactKey
}

View file

@ -61,7 +61,7 @@ fun MetricsSection(
TitledCard(title = stringResource(Res.string.logs), modifier = modifier) {
nonPositionLogs.forEach { type ->
ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
onAction(NodeDetailAction.Navigate(type.route))
onAction(NodeDetailAction.Navigate(type.routeFactory(node.num)))
}
}
}

View file

@ -61,6 +61,9 @@ import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -74,6 +77,7 @@ fun NodeItem(
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
connectionState: ConnectionState,
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
@ -91,7 +95,8 @@ fun NodeItem(
thatNode.colors.second
}
?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
val containerColor = Color(it).copy(alpha = alpha)
contentColor = contentColorFor(containerColor)
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())

View file

@ -90,7 +90,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route))
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num)))
}
}
@ -99,7 +99,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route))
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
}
}
}

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -48,7 +49,14 @@ fun NodeDetailScreen(
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
overrideNodeId: Int? = null,
) {
LaunchedEffect(overrideNodeId) {
if (overrideNodeId != null) {
viewModel.setNodeId(overrideNodeId)
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()

View file

@ -17,6 +17,8 @@
package org.meshtastic.feature.node.list
import kotlinx.coroutines.flow.map
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
@ -27,6 +29,13 @@ class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSou
val onlyDirect = uiPreferencesDataSource.onlyDirect
val showIgnored = uiPreferencesDataSource.showIgnored
val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
fun setNodeSort(option: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(option.ordinal)
}
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}

View file

@ -91,6 +91,7 @@ fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
@ -208,6 +209,8 @@ fun NodeListScreen(
null
}
val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
@ -218,6 +221,7 @@ fun NodeListScreen(
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
connectionState = connectionState,
isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,7 +33,6 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@ -44,10 +44,10 @@ import javax.inject.Inject
class NodeListViewModel
@Inject
constructor(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -63,10 +63,9 @@ constructor(
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = MutableStateFlow("")
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
private val includeUnknown = nodeFilterPreferences.includeUnknown
private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure
private val onlyOnline = nodeFilterPreferences.onlyOnline
@ -134,11 +133,11 @@ constructor(
var nodeFilterText: String
get() = _nodeFilterText.value
set(value) {
_nodeFilterText.value = value
savedStateHandle[KEY_FILTER_TEXT] = value
}
fun setSortOption(sort: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(sort.ordinal)
nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
@ -150,6 +149,10 @@ constructor(
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
companion object {
private const val KEY_FILTER_TEXT = "filter_text"
}
}
data class NodesUiState(

View file

@ -24,16 +24,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -73,7 +72,7 @@ private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MetricsViewModel
@Inject
@ -82,13 +81,16 @@ constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
private var jobs: Job? = null
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
@ -132,126 +134,157 @@ constructor(
val timeFrame: StateFlow<TimeFrame> = _timeFrame
init {
if (destNum != null) {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.onEach { (node, ourNode) ->
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(destNum)
val deviceHardware =
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
initializeFlows()
}
fun setNodeId(id: Int) {
if (destNum != id) {
destNum = id
initializeFlows()
}
}
@Suppress("LongMethod")
private fun initializeFlows() {
jobs?.cancel()
val currentDestNum = destNum
jobs =
viewModelScope.launch {
if (currentDestNum != null) {
launch {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[currentDestNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.collect { (node, ourNode) ->
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(currentDestNum)
val deviceHardware =
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
}
_state.update { state ->
state.copy(
node = actualNode,
isLocal = currentDestNum == ourNode,
deviceHardware = deviceHardware.getOrNull(),
)
}
}
}
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
)
}
}
_state.update { state ->
state.copy(
node = actualNode,
isLocal = destNum == ourNode,
deviceHardware = deviceHardware.getOrNull(),
)
}
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow
.onEach { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
)
launch {
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
)
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
}
}
}
.launchIn(viewModelScope)
meshLogRepository
.getTelemetryFrom(destNum)
.onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
)
launch {
meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
}
}
.collect {}
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum)
.onEach { meshPackets ->
_state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
}
.launchIn(viewModelScope)
launch {
meshLogRepository.getMeshPacketsFrom(
currentDestNum,
PortNum.POSITION_APP_VALUE,
).collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
}
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
launch {
meshLogRepository.getLogsFrom(
currentDestNum,
Portnums.PortNum.PAXCOUNTER_APP_VALUE,
).collect { logs ->
_state.update { state -> state.copy(paxMetrics = logs) }
}
}
launch {
firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
_state.update { state -> state.copy(latestStableFirmware = latestStable) }
}
}
launch {
firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
_state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
}
}
launch {
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.distinctUntilChanged()
.collect { firmwareEdition ->
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
}
}
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
.onEach { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
.launchIn(viewModelScope)
meshLogRepository
.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
.onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.stableRelease
.filterNotNull()
.onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.alphaRelease
.filterNotNull()
.onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
.launchIn(viewModelScope)
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.distinctUntilChanged()
.onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
.launchIn(viewModelScope)
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
override fun onCleared() {

View file

@ -42,14 +42,14 @@ import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.sig_metrics_log
import org.meshtastic.core.strings.traceroute_log
enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val route: Route) {
DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics),
NODE_MAP(Res.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap),
POSITIONS(Res.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog),
ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, NodeDetailRoutes.EnvironmentMetrics),
SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, NodeDetailRoutes.SignalMetrics),
POWER(Res.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics),
TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog),
HOST(Res.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog),
PAX(Res.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics),
enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val routeFactory: (Int) -> Route) {
DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, { NodeDetailRoutes.DeviceMetrics(it) }),
NODE_MAP(Res.string.node_map, Icons.Default.Map, { NodeDetailRoutes.NodeMap(it) }),
POSITIONS(Res.string.position_log, Icons.Default.LocationOn, { NodeDetailRoutes.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }),
SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
POWER(Res.string.power_metrics_log, Icons.Default.Power, { NodeDetailRoutes.PowerMetrics(it) }),
TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, { NodeDetailRoutes.TracerouteLog(it) }),
HOST(Res.string.host_metrics_log, Icons.Default.Memory, { NodeDetailRoutes.HostMetricsLog(it) }),
PAX(Res.string.pax_metrics_log, Icons.Default.People, { NodeDetailRoutes.PaxMetrics(it) }),
}

View file

@ -76,6 +76,8 @@ androidx-compose-material-iconsExtended = { module = "androidx.compose.material:
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" }