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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
package org.meshtastic.feature.messaging
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
|
|
@ -54,10 +55,12 @@ import javax.inject.Inject
|
|||
|
||||
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class MessageViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
quickChatActionRepository: QuickChatActionRepository,
|
||||
|
|
@ -92,12 +95,21 @@ constructor(
|
|||
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
init {
|
||||
val contactKey = savedStateHandle.get<String>("contactKey")
|
||||
if (contactKey != null) {
|
||||
contactKeyForPagedMessages.value = contactKey
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
viewModelScope.launch { _title.value = title }
|
||||
}
|
||||
|
||||
fun getMessagesFromPaged(contactKey: String): Flow<PagingData<Message>> {
|
||||
contactKeyForPagedMessages.value = contactKey
|
||||
if (contactKeyForPagedMessages.value != contactKey) {
|
||||
contactKeyForPagedMessages.value = contactKey
|
||||
}
|
||||
return pagedMessagesForContactKey
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ fun NodeListScreen(
|
|||
navigateToNodeDetails: (Int) -> Unit,
|
||||
viewModel: NodeListViewModel = hiltViewModel(),
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeNodeId: Int? = null,
|
||||
) {
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -208,6 +209,8 @@ fun NodeListScreen(
|
|||
null
|
||||
}
|
||||
|
||||
val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
|
||||
|
||||
NodeItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
thisNode = ourNode,
|
||||
|
|
@ -218,6 +221,7 @@ fun NodeListScreen(
|
|||
onLongClick = longClick,
|
||||
currentTimeMillis = currentTimeMillis,
|
||||
connectionState = connectionState,
|
||||
isActive = isActive,
|
||||
)
|
||||
val isThisNode = remember(node) { ourNode?.num == node.num }
|
||||
if (!isThisNode) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -32,7 +33,6 @@ import org.meshtastic.core.data.repository.NodeRepository
|
|||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
|
|
@ -44,10 +44,10 @@ import javax.inject.Inject
|
|||
class NodeListViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
val nodeActions: NodeActions,
|
||||
val nodeFilterPreferences: NodeFilterPreferences,
|
||||
) : ViewModel() {
|
||||
|
|
@ -63,10 +63,9 @@ constructor(
|
|||
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
|
||||
val sharedContactRequested = _sharedContactRequested.asStateFlow()
|
||||
|
||||
private val nodeSortOption =
|
||||
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
|
||||
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
|
||||
|
||||
private val _nodeFilterText = MutableStateFlow("")
|
||||
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
|
||||
private val includeUnknown = nodeFilterPreferences.includeUnknown
|
||||
private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure
|
||||
private val onlyOnline = nodeFilterPreferences.onlyOnline
|
||||
|
|
@ -134,11 +133,11 @@ constructor(
|
|||
var nodeFilterText: String
|
||||
get() = _nodeFilterText.value
|
||||
set(value) {
|
||||
_nodeFilterText.value = value
|
||||
savedStateHandle[KEY_FILTER_TEXT] = value
|
||||
}
|
||||
|
||||
fun setSortOption(sort: NodeSortOption) {
|
||||
uiPreferencesDataSource.setNodeSort(sort.ordinal)
|
||||
nodeFilterPreferences.setNodeSort(sort)
|
||||
}
|
||||
|
||||
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
|
||||
|
|
@ -150,6 +149,10 @@ constructor(
|
|||
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
|
||||
|
||||
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
|
||||
|
||||
companion object {
|
||||
private const val KEY_FILTER_TEXT = "filter_text"
|
||||
}
|
||||
}
|
||||
|
||||
data class NodesUiState(
|
||||
|
|
|
|||
|
|
@ -24,16 +24,15 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -73,7 +72,7 @@ private const val DEFAULT_ID_SUFFIX_LENGTH = 4
|
|||
private fun MeshPacket.hasValidSignal(): Boolean =
|
||||
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class MetricsViewModel
|
||||
@Inject
|
||||
|
|
@ -82,13 +81,16 @@ constructor(
|
|||
private val app: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
) : ViewModel() {
|
||||
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
|
||||
private var destNum: Int? =
|
||||
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
||||
|
||||
private var jobs: Job? = null
|
||||
|
||||
private fun MeshLog.hasValidTraceroute(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
|
@ -132,126 +134,157 @@ constructor(
|
|||
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
||||
|
||||
init {
|
||||
if (destNum != null) {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (node, ourNode) ->
|
||||
// Create a fallback node if not found in database (for hidden clients, etc.)
|
||||
val actualNode = node ?: createFallbackNode(destNum)
|
||||
val deviceHardware =
|
||||
actualNode.user.hwModel.safeNumber().let {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(it)
|
||||
initializeFlows()
|
||||
}
|
||||
|
||||
fun setNodeId(id: Int) {
|
||||
if (destNum != id) {
|
||||
destNum = id
|
||||
initializeFlows()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun initializeFlows() {
|
||||
jobs?.cancel()
|
||||
val currentDestNum = destNum
|
||||
jobs =
|
||||
viewModelScope.launch {
|
||||
if (currentDestNum != null) {
|
||||
launch {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { nodes -> nodes[currentDestNum] to nodes.keys.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.collect { (node, ourNode) ->
|
||||
// Create a fallback node if not found in database (for hidden clients, etc.)
|
||||
val actualNode = node ?: createFallbackNode(currentDestNum)
|
||||
val deviceHardware =
|
||||
actualNode.user.hwModel.safeNumber().let {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(it)
|
||||
}
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
node = actualNode,
|
||||
isLocal = currentDestNum == ourNode,
|
||||
deviceHardware = deviceHardware.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
radioConfigRepository.deviceProfileFlow.collect { profile ->
|
||||
val moduleConfig = profile.moduleConfig
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
isManaged = profile.config.security.isManaged,
|
||||
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
displayUnits = profile.config.display.units,
|
||||
)
|
||||
}
|
||||
}
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
node = actualNode,
|
||||
isLocal = destNum == ourNode,
|
||||
deviceHardware = deviceHardware.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.onEach { profile ->
|
||||
val moduleConfig = profile.moduleConfig
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
isManaged = profile.config.security.isManaged,
|
||||
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
displayUnits = profile.config.display.units,
|
||||
)
|
||||
launch {
|
||||
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
||||
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
|
||||
hostMetrics = telemetry.filter { it.hasHostMetrics() },
|
||||
)
|
||||
}
|
||||
_environmentState.update { state ->
|
||||
state.copy(
|
||||
environmentMetrics =
|
||||
telemetry.filter {
|
||||
it.hasEnvironmentMetrics() &&
|
||||
it.environmentMetrics.hasRelativeHumidity() &&
|
||||
it.environmentMetrics.hasTemperature() &&
|
||||
!it.environmentMetrics.temperature.isNaN()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getTelemetryFrom(destNum)
|
||||
.onEach { telemetry ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
||||
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
|
||||
hostMetrics = telemetry.filter { it.hasHostMetrics() },
|
||||
)
|
||||
launch {
|
||||
meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets ->
|
||||
_state.update { state ->
|
||||
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
|
||||
}
|
||||
}
|
||||
}
|
||||
_environmentState.update { state ->
|
||||
state.copy(
|
||||
environmentMetrics =
|
||||
telemetry.filter {
|
||||
it.hasEnvironmentMetrics() &&
|
||||
it.environmentMetrics.hasRelativeHumidity() &&
|
||||
it.environmentMetrics.hasTemperature() &&
|
||||
!it.environmentMetrics.temperature.isNaN()
|
||||
},
|
||||
)
|
||||
|
||||
launch {
|
||||
combine(
|
||||
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE),
|
||||
) { request, response ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
tracerouteRequests = request.filter { it.hasValidTraceroute() },
|
||||
tracerouteResults = response,
|
||||
)
|
||||
}
|
||||
}
|
||||
.collect {}
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMeshPacketsFrom(destNum)
|
||||
.onEach { meshPackets ->
|
||||
_state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
launch {
|
||||
meshLogRepository.getMeshPacketsFrom(
|
||||
currentDestNum,
|
||||
PortNum.POSITION_APP_VALUE,
|
||||
).collect { packets ->
|
||||
val distinctPositions =
|
||||
packets
|
||||
.mapNotNull { it.toPosition() }
|
||||
.asFlow()
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.time == new.time ||
|
||||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
|
||||
}
|
||||
.toList()
|
||||
_state.update { state -> state.copy(positionLogs = distinctPositions) }
|
||||
}
|
||||
}
|
||||
|
||||
combine(
|
||||
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||
meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||
) { request, response ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
tracerouteRequests = request.filter { it.hasValidTraceroute() },
|
||||
tracerouteResults = response,
|
||||
)
|
||||
launch {
|
||||
meshLogRepository.getLogsFrom(
|
||||
currentDestNum,
|
||||
Portnums.PortNum.PAXCOUNTER_APP_VALUE,
|
||||
).collect { logs ->
|
||||
_state.update { state -> state.copy(paxMetrics = logs) }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
|
||||
_state.update { state -> state.copy(latestStableFirmware = latestStable) }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
|
||||
_state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
meshLogRepository
|
||||
.getMyNodeInfo()
|
||||
.map { it?.firmwareEdition }
|
||||
.distinctUntilChanged()
|
||||
.collect { firmwareEdition ->
|
||||
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("MetricsViewModel created")
|
||||
} else {
|
||||
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
|
||||
.onEach { packets ->
|
||||
val distinctPositions =
|
||||
packets
|
||||
.mapNotNull { it.toPosition() }
|
||||
.asFlow()
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.time == new.time ||
|
||||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
|
||||
}
|
||||
.toList()
|
||||
_state.update { state -> state.copy(positionLogs = distinctPositions) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
|
||||
.onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firmwareReleaseRepository.stableRelease
|
||||
.filterNotNull()
|
||||
.onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firmwareReleaseRepository.alphaRelease
|
||||
.filterNotNull()
|
||||
.onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMyNodeInfo()
|
||||
.map { it?.firmwareEdition }
|
||||
.distinctUntilChanged()
|
||||
.onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
Timber.d("MetricsViewModel created")
|
||||
} else {
|
||||
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue