mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
d60e84fa4d
commit
78274c7923
19 changed files with 630 additions and 222 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue