From 3fceb1fae1a5a6d2c2565ff72326b32e5725039d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 23 Aug 2025 14:35:57 -0500 Subject: [PATCH] feat(navigation): Add deep links to other screens (#2811) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 6 +- .../mesh/navigation/ChannelsRoutes.kt | 37 +- .../mesh/navigation/ConnectionsRoutes.kt | 42 +- .../mesh/navigation/ContactsRoutes.kt | 26 +- .../geeksville/mesh/navigation/MapRoutes.kt | 3 +- .../geeksville/mesh/navigation/NavGraph.kt | 7 +- .../geeksville/mesh/navigation/NodesRoutes.kt | 214 ++++++-- .../mesh/navigation/RadioConfigRoutes.kt | 461 ++++++++++++++---- 8 files changed, 605 insertions(+), 191 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 61fcd9d50..7ca4e26eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,11 +162,7 @@ - - - - - + diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsRoutes.kt index cbec3dff0..da929f2ad 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsRoutes.kt @@ -22,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink import androidx.navigation.navigation import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.radioconfig.components.ChannelConfigScreen @@ -31,48 +32,36 @@ import kotlinx.serialization.Serializable @Serializable sealed class ChannelsRoutes { - @Serializable - data object ChannelsGraph : Graph + @Serializable data object ChannelsGraph : Graph - @Serializable - data object Channels : Route + @Serializable data object Channels : Route } -/** - * Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. - */ +/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */ fun NavGraphBuilder.channelsGraph(navController: NavHostController, uiViewModel: UIViewModel) { - navigation( - startDestination = ChannelsRoutes.Channels, - ) { - composable { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + navigation(startDestination = ChannelsRoutes.Channels) { + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/channels")), + ) { backStackEntry -> + val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) } ChannelScreen( viewModel = uiViewModel, radioConfigViewModel = hiltViewModel(parentEntry), - onNavigate = { route -> navController.navigate(route) } + onNavigate = { route -> navController.navigate(route) }, ) } configRoutes(navController) } } -private fun NavGraphBuilder.configRoutes( - navController: NavHostController, -) { +private fun NavGraphBuilder.configRoutes(navController: NavHostController) { ConfigRoute.entries.forEach { configRoute -> composable(configRoute.route::class) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) } when (configRoute) { ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry)) ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry)) - else -> Unit + else -> Unit // Should not happen if ConfigRoute enum is exhaustive for this context } } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsRoutes.kt index ba8a1d3e4..d9f929bde 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsRoutes.kt @@ -32,57 +32,43 @@ import kotlinx.serialization.Serializable @Serializable sealed class ConnectionsRoutes { - @Serializable - data object ConnectionsGraph : Graph + @Serializable data object ConnectionsGraph : Graph - @Serializable - data object Connections : Route + @Serializable data object Connections : Route } -/** - * Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. - */ +/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */ fun NavGraphBuilder.connectionsGraph( navController: NavHostController, uiViewModel: UIViewModel, - bluetoothViewModel: BluetoothViewModel + bluetoothViewModel: BluetoothViewModel, ) { - navigation( - startDestination = ConnectionsRoutes.Connections, - ) { + @Suppress("ktlint:standard:max-line-length") + navigation(startDestination = ConnectionsRoutes.Connections) { composable( deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/connections" - action = "android.intent.action.VIEW" - } - ) + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/connections"), + ), ) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + val parentEntry = + remember(backStackEntry) { navController.getBackStackEntry(ConnectionsRoutes.ConnectionsGraph) } ConnectionsScreen( uiViewModel = uiViewModel, bluetoothViewModel = bluetoothViewModel, radioConfigViewModel = hiltViewModel(parentEntry), onNavigateToRadioConfig = { navController.navigate(RadioConfigRoutes.RadioConfig()) }, onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, - onConfigNavigate = { route -> navController.navigate(route) } + onConfigNavigate = { route -> navController.navigate(route) }, ) } configRoutes(navController) } } -private fun NavGraphBuilder.configRoutes( - navController: NavHostController, -) { +private fun NavGraphBuilder.configRoutes(navController: NavHostController) { composable { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + val parentEntry = + remember(backStackEntry) { navController.getBackStackEntry(ConnectionsRoutes.ConnectionsGraph) } LoRaConfigScreen(hiltViewModel(parentEntry)) } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt index 1e4d206ba..980d84c4d 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt @@ -42,9 +42,12 @@ sealed class ContactsRoutes { @Serializable data object ContactsGraph : Graph } +@Suppress("LongMethod") fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: UIViewModel) { navigation(startDestination = ContactsRoutes.Contacts) { - composable { + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/contacts")), + ) { ContactsScreen( uiViewModel, onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, @@ -55,10 +58,10 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: composable( deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}" - action = "android.intent.action.VIEW" - }, + navDeepLink( + basePath = + "$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended + ), ), ) { backStackEntry -> val args = backStackEntry.toRoute() @@ -76,10 +79,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: composable( deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}" - action = "android.intent.action.VIEW" - }, + navDeepLink( + basePath = "$DEEP_LINK_BASE_URI/share", // ?message={message} is auto-appended + ), ), ) { backStackEntry -> val message = backStackEntry.toRoute().message @@ -89,5 +91,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: } } } - composable { QuickChatScreen() } + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/quick_chat")), + ) { + QuickChatScreen() + } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt index 48a2044aa..9c0947264 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt @@ -20,6 +20,7 @@ package com.geeksville.mesh.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.map.MapView import com.geeksville.mesh.ui.map.MapViewModel @@ -30,7 +31,7 @@ sealed class MapRoutes { } fun NavGraphBuilder.mapGraph(navController: NavHostController, uiViewModel: UIViewModel, mapViewModel: MapViewModel) { - composable { + composable(deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/map"))) { MapView( uiViewModel = uiViewModel, mapViewModel = mapViewModel, diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt index 9d6364af5..74b83a74f 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -27,6 +27,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navDeepLink import com.geeksville.mesh.R import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel @@ -79,7 +80,11 @@ fun NavGraph( mapGraph(navController, uIViewModel, mapViewModel) channelsGraph(navController, uIViewModel) connectionsGraph(navController, uIViewModel, bluetoothViewModel) - composable { DebugScreen() } + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/debug_panel")), + ) { + DebugScreen() + } radioConfigGraph(navController, uIViewModel) } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt index 61dc747d8..bba6fe66d 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt @@ -27,14 +27,18 @@ import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.PermScanWifi import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Router +import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import androidx.navigation.navDeepLink import com.geeksville.mesh.R +import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen @@ -60,7 +64,6 @@ sealed class NodesRoutes { } sealed class NodeDetailRoutes { - @Serializable data object DeviceMetrics : Route @Serializable data object NodeMap : Route @@ -82,7 +85,9 @@ sealed class NodeDetailRoutes { fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) { navigation(startDestination = NodesRoutes.Nodes) { - composable { + composable( + deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/nodes")), + ) { NodeScreen( model = uiViewModel, navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, @@ -93,14 +98,19 @@ fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UI } } +@Suppress("LongMethod") fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) { navigation(startDestination = NodesRoutes.NodeDetail()) { - composable { backStackEntry -> + composable( + deepLinks = + listOf( + navDeepLink( // Handles both /node and /node/{destNum} due to destNum: Int? + basePath = "$DEEP_LINK_BASE_URI/node", + ), + ), + ) { backStackEntry -> val parentEntry = - remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } NodeDetailScreen( uiViewModel = uiViewModel, navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, @@ -109,39 +119,171 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewMode viewModel = hiltViewModel(parentEntry), ) } - NodeDetailRoute.entries.forEach { nodeDetailRoute -> - composable(nodeDetailRoute.route::class) { backStackEntry -> - val parentEntry = - remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } - when (nodeDetailRoute) { - NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.NODE_MAP -> NodeMapScreen(uiViewModel, hiltViewModel(parentEntry)) - NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(viewModel = hiltViewModel(parentEntry)) - - NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.PAX -> PaxMetricsScreen(hiltViewModel(parentEntry)) - } + NodeDetailRoute.entries.forEach { entry -> + when (entry.route) { + is NodeDetailRoutes.DeviceMetrics -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.NodeMap -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.PositionLog -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.EnvironmentMetrics -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.SignalMetrics -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.PowerMetrics -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.TracerouteLog -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.HostMetricsLog -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + is NodeDetailRoutes.PaxMetrics -> + addNodeDetailScreenComposable( + navController, + uiViewModel, + entry, + entry.screenComposable, + ) + else -> Unit } } } } -enum class NodeDetailRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?) { - DEVICE(R.string.device, NodeDetailRoutes.DeviceMetrics, Icons.Default.Router), - NODE_MAP(R.string.node_map, NodeDetailRoutes.NodeMap, Icons.Default.LocationOn), - POSITION_LOG(R.string.position_log, NodeDetailRoutes.PositionLog, Icons.Default.LocationOn), - ENVIRONMENT(R.string.environment, NodeDetailRoutes.EnvironmentMetrics, Icons.Default.LightMode), - SIGNAL(R.string.signal, NodeDetailRoutes.SignalMetrics, Icons.Default.CellTower), - TRACEROUTE(R.string.traceroute, NodeDetailRoutes.TracerouteLog, Icons.Default.PermScanWifi), - POWER(R.string.power, NodeDetailRoutes.PowerMetrics, Icons.Default.Power), - HOST(R.string.host, NodeDetailRoutes.HostMetricsLog, Icons.Default.Memory), - PAX(R.string.pax, NodeDetailRoutes.PaxMetrics, Icons.Default.People), +/** + * 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 uiViewModel The shared [UIViewModel], passed to the [screenContent]. + * @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] and the [UIViewModel]. + */ +private inline fun NavGraphBuilder.addNodeDetailScreenComposable( + navController: NavHostController, + uiViewModel: UIViewModel, + routeInfo: NodeDetailRoute, + crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, passedUiViewModel: UIViewModel) -> Unit, +) { + composable( + deepLinks = + listOf( + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/${routeInfo.name.lowercase()}"), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/${routeInfo.name.lowercase()}"), + ), + ) { backStackEntry -> + val parentGraphBackStackEntry = + remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } + val metricsViewModel = hiltViewModel(parentGraphBackStackEntry) + screenContent(metricsViewModel, uiViewModel) + } +} + +enum class NodeDetailRoute( + @StringRes val title: Int, + val route: Route, + val icon: ImageVector?, + val screenComposable: @Composable (metricsViewModel: MetricsViewModel, uiViewModel: UIViewModel) -> Unit, +) { + DEVICE( + R.string.device, + NodeDetailRoutes.DeviceMetrics, + Icons.Default.Router, + { metricsVM, _ -> DeviceMetricsScreen(metricsVM) }, + ), + NODE_MAP( + R.string.node_map, + NodeDetailRoutes.NodeMap, + Icons.Default.LocationOn, + { metricsVM, uiVM -> NodeMapScreen(uiVM, metricsVM) }, + ), + POSITION_LOG( + R.string.position_log, + NodeDetailRoutes.PositionLog, + Icons.Default.LocationOn, + { metricsVM, _ -> PositionLogScreen(metricsVM) }, + ), + ENVIRONMENT( + R.string.environment, + NodeDetailRoutes.EnvironmentMetrics, + Icons.Default.LightMode, + { metricsVM, _ -> EnvironmentMetricsScreen(metricsVM) }, + ), + SIGNAL( + R.string.signal, + NodeDetailRoutes.SignalMetrics, + Icons.Default.CellTower, + { metricsVM, _ -> SignalMetricsScreen(metricsVM) }, + ), + TRACEROUTE( + R.string.traceroute, + NodeDetailRoutes.TracerouteLog, + Icons.Default.PermScanWifi, + { metricsVM, _ -> TracerouteLogScreen(viewModel = metricsVM) }, + ), + POWER( + R.string.power, + NodeDetailRoutes.PowerMetrics, + Icons.Default.Power, + { metricsVM, _ -> PowerMetricsScreen(metricsVM) }, + ), + HOST( + R.string.host, + NodeDetailRoutes.HostMetricsLog, + Icons.Default.Memory, + { metricsVM, _ -> HostMetricsLogScreen(metricsVM) }, + ), + PAX( + R.string.pax, + NodeDetailRoutes.PaxMetrics, + Icons.Default.People, + { metricsVM, _ -> PaxMetricsScreen(metricsVM) }, + ), } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt index 5676376ac..fc0746d6d 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt @@ -42,18 +42,23 @@ import androidx.compose.material.icons.filled.SettingsRemote import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Usb import androidx.compose.material.icons.filled.Wifi +import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink import androidx.navigation.navigation +import com.geeksville.mesh.AdminProtos import com.geeksville.mesh.MeshProtos.DeviceMetadata import com.geeksville.mesh.R import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.radioconfig.CleanNodeDatabaseScreen import com.geeksville.mesh.ui.radioconfig.RadioConfigScreen +import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel import com.geeksville.mesh.ui.radioconfig.components.AmbientLightingConfigScreen import com.geeksville.mesh.ui.radioconfig.components.AudioConfigScreen import com.geeksville.mesh.ui.radioconfig.components.BluetoothConfigScreen @@ -136,100 +141,301 @@ sealed class RadioConfigRoutes { fun getNavRouteFrom(routeName: String): Route? = ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route +@Suppress("LongMethod") fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) { navigation(startDestination = RadioConfigRoutes.RadioConfig()) { - composable { backStackEntry -> + composable( + deepLinks = + listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/radio_config")), + ) { backStackEntry -> val parentEntry = - remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + remember(backStackEntry) { navController.getBackStackEntry(RadioConfigRoutes.RadioConfigGraph::class) } RadioConfigScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) { navController.navigate(it) { popUpTo(RadioConfigRoutes.RadioConfig()) { inclusive = false } } } } - composable { CleanNodeDatabaseScreen() } - configRoutes(navController) - moduleRoutes(navController) + composable( + deepLinks = + listOf( + navDeepLink( + basePath = "$DEEP_LINK_BASE_URI/radio_config/clean_node_db", + ), + ), + ) { + CleanNodeDatabaseScreen() + } + configRoutesScreens(navController) + moduleRoutesScreens(navController) } } -private fun NavGraphBuilder.configRoutes(navController: NavHostController) { - ConfigRoute.entries.forEach { configRoute -> - composable(configRoute.route::class) { backStackEntry -> - val parentEntry = - remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } - when (configRoute) { - ConfigRoute.USER -> UserConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.DEVICE -> DeviceConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.POSITION -> PositionConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.POWER -> PowerConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.NETWORK -> NetworkConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.DISPLAY -> DisplayConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(hiltViewModel(parentEntry)) - ConfigRoute.SECURITY -> SecurityConfigScreen(hiltViewModel(parentEntry)) - } +/** + * Helper to define a composable route for a radio configuration screen within the radio config graph. + * + * This function simplifies adding screens by handling common tasks like: + * - Setting up deep links based on the route's name. + * - Retrieving the parent [NavBackStackEntry] for the [RadioConfigRoutes.RadioConfigGraph]. + * - Providing the [RadioConfigViewModel] scoped to the parent graph, which the [screenContent] will use. + * + * @param R The type of the [Route] object, must be serializable. + * @param navController The [NavHostController] for navigation. + * @param routeNameString The string name of the route (from the enum entry's name) used for deep link paths. + * @param screenContent A lambda that defines the composable content for the screen. It receives the parent-scoped + * [RadioConfigViewModel]. + */ +private inline fun NavGraphBuilder.addRadioConfigScreenComposable( + navController: NavHostController, + routeNameString: String, + crossinline screenContent: @Composable (viewModel: RadioConfigViewModel) -> Unit, +) { + composable( + deepLinks = + listOf( + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/radio_config/{destNum}/${routeNameString.lowercase()}"), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/radio_config/${routeNameString.lowercase()}"), + ), + ) { backStackEntry -> + val parentEntry = + remember(backStackEntry) { navController.getBackStackEntry(RadioConfigRoutes.RadioConfigGraph::class) } + val viewModel = hiltViewModel(parentEntry) + screenContent(viewModel) + } +} + +@Suppress("LongMethod") +private fun NavGraphBuilder.configRoutesScreens(navController: NavHostController) { + ConfigRoute.entries.forEach { entry -> + when (entry.route) { + is RadioConfigRoutes.User -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.ChannelConfig -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Device -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Position -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Power -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Network -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Display -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.LoRa -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Bluetooth -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Security -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + else -> Unit // Should not happen if ConfigRoute enum is exhaustive for this context } } } -@Suppress("CyclomaticComplexMethod") -private fun NavGraphBuilder.moduleRoutes(navController: NavHostController) { - ModuleRoute.entries.forEach { moduleRoute -> - composable(moduleRoute.route::class) { backStackEntry -> - val parentEntry = - remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } - when (moduleRoute) { - ModuleRoute.MQTT -> MQTTConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.SERIAL -> SerialConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen(hiltViewModel(parentEntry)) - - ModuleRoute.STORE_FORWARD -> StoreForwardConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.TELEMETRY -> TelemetryConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen(hiltViewModel(parentEntry)) - - ModuleRoute.AUDIO -> AudioConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen(hiltViewModel(parentEntry)) - - ModuleRoute.NEIGHBOR_INFO -> NeighborInfoConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen(hiltViewModel(parentEntry)) - - ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen(hiltViewModel(parentEntry)) - - ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(hiltViewModel(parentEntry)) - } +@Suppress("LongMethod", "CyclomaticComplexMethod") +private fun NavGraphBuilder.moduleRoutesScreens(navController: NavHostController) { + ModuleRoute.entries.forEach { entry -> + when (entry.route) { + is RadioConfigRoutes.MQTT -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Serial -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.ExtNotification -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.StoreForward -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.RangeTest -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Telemetry -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.CannedMessage -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Audio -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.RemoteHardware -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.NeighborInfo -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.AmbientLighting -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.DetectionSensor -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + is RadioConfigRoutes.Paxcounter -> + addRadioConfigScreenComposable( + navController, + entry.name, + entry.screenComposable, + ) + else -> Unit // Should not happen if ModuleRoute enum is exhaustive for this context } } } -// Config (type = AdminProtos.AdminMessage.ConfigType) @Suppress("MagicNumber") -enum class ConfigRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { - USER(R.string.user, RadioConfigRoutes.User, Icons.Default.Person, 0), - CHANNELS(R.string.channels, RadioConfigRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0), - DEVICE(R.string.device, RadioConfigRoutes.Device, Icons.Default.Router, 0), - POSITION(R.string.position, RadioConfigRoutes.Position, Icons.Default.LocationOn, 1), - POWER(R.string.power, RadioConfigRoutes.Power, Icons.Default.Power, 2), - NETWORK(R.string.network, RadioConfigRoutes.Network, Icons.Default.Wifi, 3), - DISPLAY(R.string.display, RadioConfigRoutes.Display, Icons.Default.DisplaySettings, 4), - LORA(R.string.lora, RadioConfigRoutes.LoRa, Icons.Default.CellTower, 5), - BLUETOOTH(R.string.bluetooth, RadioConfigRoutes.Bluetooth, Icons.Default.Bluetooth, 6), - SECURITY(R.string.security, RadioConfigRoutes.Security, Icons.Default.Security, 7), +enum class ConfigRoute( + @StringRes val title: Int, + val route: Route, + val icon: ImageVector?, + val type: Int = 0, + val screenComposable: @Composable (viewModel: RadioConfigViewModel) -> Unit, +) { + USER(R.string.user, RadioConfigRoutes.User, Icons.Default.Person, 0, { vm -> UserConfigScreen(vm) }), + CHANNELS( + R.string.channels, + RadioConfigRoutes.ChannelConfig, + Icons.AutoMirrored.Default.List, + 0, + { vm -> ChannelConfigScreen(vm) }, + ), + DEVICE( + R.string.device, + RadioConfigRoutes.Device, + Icons.Default.Router, + AdminProtos.AdminMessage.ConfigType.DEVICE_CONFIG_VALUE, + { vm -> DeviceConfigScreen(vm) }, + ), + POSITION( + R.string.position, + RadioConfigRoutes.Position, + Icons.Default.LocationOn, + AdminProtos.AdminMessage.ConfigType.POSITION_CONFIG_VALUE, + { vm -> PositionConfigScreen(vm) }, + ), + POWER( + R.string.power, + RadioConfigRoutes.Power, + Icons.Default.Power, + AdminProtos.AdminMessage.ConfigType.POWER_CONFIG_VALUE, + { vm -> PowerConfigScreen(vm) }, + ), + NETWORK( + R.string.network, + RadioConfigRoutes.Network, + Icons.Default.Wifi, + AdminProtos.AdminMessage.ConfigType.NETWORK_CONFIG_VALUE, + { vm -> NetworkConfigScreen(vm) }, + ), + DISPLAY( + R.string.display, + RadioConfigRoutes.Display, + Icons.Default.DisplaySettings, + AdminProtos.AdminMessage.ConfigType.DISPLAY_CONFIG_VALUE, + { vm -> DisplayConfigScreen(vm) }, + ), + LORA( + R.string.lora, + RadioConfigRoutes.LoRa, + Icons.Default.CellTower, + AdminProtos.AdminMessage.ConfigType.LORA_CONFIG_VALUE, + { vm -> LoRaConfigScreen(vm) }, + ), + BLUETOOTH( + R.string.bluetooth, + RadioConfigRoutes.Bluetooth, + Icons.Default.Bluetooth, + AdminProtos.AdminMessage.ConfigType.BLUETOOTH_CONFIG_VALUE, + { vm -> BluetoothConfigScreen(vm) }, + ), + SECURITY( + R.string.security, + RadioConfigRoutes.Security, + Icons.Default.Security, + AdminProtos.AdminMessage.ConfigType.SECURITY_CONFIG_VALUE, + { vm -> SecurityConfigScreen(vm) }, + ), ; companion object { fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { when { - metadata == null -> true + metadata == null -> true // Include all routes if metadata is null it == BLUETOOTH -> metadata.hasBluetooth it == NETWORK -> metadata.hasWifi || metadata.hasEthernet else -> true // Include all other routes by default @@ -238,22 +444,105 @@ enum class ConfigRoute(@StringRes val title: Int, val route: Route, val icon: Im } } -// ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType) @Suppress("MagicNumber") -enum class ModuleRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { - MQTT(R.string.mqtt, RadioConfigRoutes.MQTT, Icons.Default.Cloud, 0), - SERIAL(R.string.serial, RadioConfigRoutes.Serial, Icons.Default.Usb, 1), - EXT_NOTIFICATION(R.string.external_notification, RadioConfigRoutes.ExtNotification, Icons.Default.Notifications, 2), - STORE_FORWARD(R.string.store_forward, RadioConfigRoutes.StoreForward, Icons.AutoMirrored.Default.Forward, 3), - RANGE_TEST(R.string.range_test, RadioConfigRoutes.RangeTest, Icons.Default.Speed, 4), - TELEMETRY(R.string.telemetry, RadioConfigRoutes.Telemetry, Icons.Default.DataUsage, 5), - CANNED_MESSAGE(R.string.canned_message, RadioConfigRoutes.CannedMessage, Icons.AutoMirrored.Default.Message, 6), - AUDIO(R.string.audio, RadioConfigRoutes.Audio, Icons.AutoMirrored.Default.VolumeUp, 7), - REMOTE_HARDWARE(R.string.remote_hardware, RadioConfigRoutes.RemoteHardware, Icons.Default.SettingsRemote, 8), - NEIGHBOR_INFO(R.string.neighbor_info, RadioConfigRoutes.NeighborInfo, Icons.Default.People, 9), - AMBIENT_LIGHTING(R.string.ambient_lighting, RadioConfigRoutes.AmbientLighting, Icons.Default.LightMode, 10), - DETECTION_SENSOR(R.string.detection_sensor, RadioConfigRoutes.DetectionSensor, Icons.Default.Sensors, 11), - PAXCOUNTER(R.string.paxcounter, RadioConfigRoutes.Paxcounter, Icons.Default.PermScanWifi, 12), +enum class ModuleRoute( + @StringRes val title: Int, + val route: Route, + val icon: ImageVector?, + val type: Int = 0, + val screenComposable: @Composable (viewModel: RadioConfigViewModel) -> Unit, +) { + MQTT( + R.string.mqtt, + RadioConfigRoutes.MQTT, + Icons.Default.Cloud, + AdminProtos.AdminMessage.ModuleConfigType.MQTT_CONFIG_VALUE, + { vm -> MQTTConfigScreen(vm) }, + ), + SERIAL( + R.string.serial, + RadioConfigRoutes.Serial, + Icons.Default.Usb, + AdminProtos.AdminMessage.ModuleConfigType.SERIAL_CONFIG_VALUE, + { vm -> SerialConfigScreen(vm) }, + ), + EXT_NOTIFICATION( + R.string.external_notification, + RadioConfigRoutes.ExtNotification, + Icons.Default.Notifications, + AdminProtos.AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG_VALUE, + { vm -> ExternalNotificationConfigScreen(vm) }, + ), + STORE_FORWARD( + R.string.store_forward, + RadioConfigRoutes.StoreForward, + Icons.AutoMirrored.Default.Forward, + AdminProtos.AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG_VALUE, + { vm -> StoreForwardConfigScreen(vm) }, + ), + RANGE_TEST( + R.string.range_test, + RadioConfigRoutes.RangeTest, + Icons.Default.Speed, + AdminProtos.AdminMessage.ModuleConfigType.RANGETEST_CONFIG_VALUE, + { vm -> RangeTestConfigScreen(vm) }, + ), + TELEMETRY( + R.string.telemetry, + RadioConfigRoutes.Telemetry, + Icons.Default.DataUsage, + AdminProtos.AdminMessage.ModuleConfigType.TELEMETRY_CONFIG_VALUE, + { vm -> TelemetryConfigScreen(vm) }, + ), + CANNED_MESSAGE( + R.string.canned_message, + RadioConfigRoutes.CannedMessage, + Icons.AutoMirrored.Default.Message, + AdminProtos.AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG_VALUE, + { vm -> CannedMessageConfigScreen(vm) }, + ), + AUDIO( + R.string.audio, + RadioConfigRoutes.Audio, + Icons.AutoMirrored.Default.VolumeUp, + AdminProtos.AdminMessage.ModuleConfigType.AUDIO_CONFIG_VALUE, + { vm -> AudioConfigScreen(vm) }, + ), + REMOTE_HARDWARE( + R.string.remote_hardware, + RadioConfigRoutes.RemoteHardware, + Icons.Default.SettingsRemote, + AdminProtos.AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG_VALUE, + { vm -> RemoteHardwareConfigScreen(vm) }, + ), + NEIGHBOR_INFO( + R.string.neighbor_info, + RadioConfigRoutes.NeighborInfo, + Icons.Default.People, + AdminProtos.AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG_VALUE, + { vm -> NeighborInfoConfigScreen(vm) }, + ), + AMBIENT_LIGHTING( + R.string.ambient_lighting, + RadioConfigRoutes.AmbientLighting, + Icons.Default.LightMode, + AdminProtos.AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG_VALUE, + { vm -> AmbientLightingConfigScreen(vm) }, + ), + DETECTION_SENSOR( + R.string.detection_sensor, + RadioConfigRoutes.DetectionSensor, + Icons.Default.Sensors, + AdminProtos.AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG_VALUE, + { vm -> DetectionSensorConfigScreen(vm) }, + ), + PAXCOUNTER( + R.string.paxcounter, + RadioConfigRoutes.Paxcounter, + Icons.Default.PermScanWifi, + AdminProtos.AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG_VALUE, + { vm -> PaxcounterConfigScreen(vm) }, + ), ; val bitfield: Int @@ -262,7 +551,7 @@ enum class ModuleRoute(@StringRes val title: Int, val route: Route, val icon: Im companion object { fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { when (metadata) { - null -> true + null -> true // Include all routes if metadata is null else -> metadata.excludedModules and it.bitfield == 0 } }