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

@ -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,
)
}
}
}