diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f4d4536e..9f1f8e51f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt index fad262b75..60f3e60fd 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt @@ -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( deepLinks = listOf(navDeepLink(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( deepLinks = @@ -63,13 +49,11 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE ), ) { backStackEntry -> val args = backStackEntry.toRoute() - 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, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index 2adb4e103..85e0043ef 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -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) { navigation(startDestination = NodesRoutes.Nodes) { composable( deepLinks = listOf(navDeepLink(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) { + // 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(startDestination = NodesRoutes.NodeDetail()) { composable( 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() + // 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(parentGraphBackStackEntry), - onNavigateUp = navController::navigateUp, - ) + val vm = hiltViewModel(parentGraphBackStackEntry) + NodeMapScreen(vm, onNavigateUp = navController::navigateUp) } NodeDetailRoute.entries.forEach { entry -> - when (entry.route) { - is NodeDetailRoutes.DeviceMetrics -> + when (entry.routeClass) { + NodeDetailRoutes.DeviceMetrics::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.PositionLog -> + ) { + it.destNum + } + NodeDetailRoutes.PositionLog::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.EnvironmentMetrics -> + ) { + it.destNum + } + NodeDetailRoutes.EnvironmentMetrics::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.SignalMetrics -> + ) { + it.destNum + } + NodeDetailRoutes.SignalMetrics::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.PowerMetrics -> + ) { + it.destNum + } + NodeDetailRoutes.PowerMetrics::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.TracerouteLog -> + ) { + it.destNum + } + NodeDetailRoutes.TracerouteLog::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.HostMetricsLog -> + ) { + it.destNum + } + NodeDetailRoutes.HostMetricsLog::class -> addNodeDetailScreenComposable( navController, entry, entry.screenComposable, - ) - is NodeDetailRoutes.PaxMetrics -> + ) { + it.destNum + } + NodeDetailRoutes.PaxMetrics::class -> addNodeDetailScreenComposable( 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 NavGraphBuilder.addNodeDetailScreenComposable( navController: NavHostController, routeInfo: NodeDetailRoute, crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit, + crossinline getDestNum: (R) -> Int, ) { composable( deepLinks = @@ -207,61 +220,66 @@ private inline fun NavGraphBuilder.addNodeDetailScreenCompos val parentGraphBackStackEntry = remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } val metricsViewModel = hiltViewModel(parentGraphBackStackEntry) + + val args = backStackEntry.toRoute() + val destNum = getDestNum(args) + metricsViewModel.setNodeId(destNum) + screenContent(metricsViewModel, navController::navigateUp) } } enum class NodeDetailRoute( val title: StringResource, - val route: Route, + val routeClass: KClass, 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) }, ), diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt new file mode 100644 index 000000000..fb3dfaa08 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt @@ -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 . + */ + +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, + initialContactKey: String? = null, + initialMessage: String = "", +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + 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, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt index 881b3d537..2619cb473 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 2effcacdb..84ba733ac 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -112,6 +112,7 @@ fun ContactsScreen( onNavigateToMessages: (String) -> Unit = {}, onNavigateToNodeDetails: (Int) -> Unit = {}, scrollToTopEvents: Flow? = 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, channelPlaceholders: List, selectedList: List, + 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, visiblePlaceholders: List, selectedList: List, + 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, selectedList: List, + 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, selectedList: List, + 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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt new file mode 100644 index 000000000..1b4779e36 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt @@ -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 . + */ + +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, + initialNodeId: Int? = null, + onNavigateToMessages: (String) -> Unit = {}, +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + 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, + ) + } + } +} diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index 459cdf078..cb592d958 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -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 { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 4be413f9b..0ea758b4c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -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("contactKey") + if (contactKey != null) { + contactKeyForPagedMessages.value = contactKey + } + } + fun setTitle(title: String) { viewModelScope.launch { _title.value = title } } fun getMessagesFromPaged(contactKey: String): Flow> { - contactKeyForPagedMessages.value = contactKey + if (contactKeyForPagedMessages.value != contactKey) { + contactKeyForPagedMessages.value = contactKey + } return pagedMessagesForContactKey } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt index 035683531..43a828840 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt @@ -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))) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 66720af7d..a6f4cbaea 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -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()) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 48b1e3a8c..a3b270753 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -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))) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index 19bda743c..3c09f7d08 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -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() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index 51620b78e..1fdd31566 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -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) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 0c99acc88..ddd650f05 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -91,6 +91,7 @@ fun NodeListScreen( navigateToNodeDetails: (Int) -> Unit, viewModel: NodeListViewModel = hiltViewModel(), scrollToTopEvents: Flow? = 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) { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 497bed25d..b52069652 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -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 = 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( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 381732b39..004637957 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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().destNum + private var destNum: Int? = + runCatching { savedStateHandle.toRoute().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 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() { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt index 51a7fff9a..0076b4405 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -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) }), } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10a65ecb4..9c576e245 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }