feat(navigation): Add deep links to other screens (#2811)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-23 14:35:57 -05:00 committed by GitHub
parent 7d33365095
commit 3fceb1fae1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 605 additions and 191 deletions

View file

@ -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<ChannelsRoutes.ChannelsGraph>(
startDestination = ChannelsRoutes.Channels,
) {
composable<ChannelsRoutes.Channels> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
val parentRoute = backStackEntry.destination.parent!!.route!!
navController.getBackStackEntry(parentRoute)
}
navigation<ChannelsRoutes.ChannelsGraph>(startDestination = ChannelsRoutes.Channels) {
composable<ChannelsRoutes.Channels>(
deepLinks = listOf(navDeepLink<ChannelsRoutes.Channels>(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
}
}
}

View file

@ -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<ConnectionsRoutes.ConnectionsGraph>(
startDestination = ConnectionsRoutes.Connections,
) {
@Suppress("ktlint:standard:max-line-length")
navigation<ConnectionsRoutes.ConnectionsGraph>(startDestination = ConnectionsRoutes.Connections) {
composable<ConnectionsRoutes.Connections>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/connections"
action = "android.intent.action.VIEW"
}
)
navDeepLink<ConnectionsRoutes.Connections>(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<RadioConfigRoutes.LoRa> { 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))
}
}

View file

@ -42,9 +42,12 @@ sealed class ContactsRoutes {
@Serializable data object ContactsGraph : Graph
}
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) {
composable<ContactsRoutes.Contacts> {
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(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<ContactsRoutes.Messages>(
deepLinks =
listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
action = "android.intent.action.VIEW"
},
navDeepLink<ContactsRoutes.Messages>(
basePath =
"$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended
),
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
@ -76,10 +79,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel:
composable<ContactsRoutes.Share>(
deepLinks =
listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
action = "android.intent.action.VIEW"
},
navDeepLink<ContactsRoutes.Share>(
basePath = "$DEEP_LINK_BASE_URI/share", // ?message={message} is auto-appended
),
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
@ -89,5 +91,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel:
}
}
}
composable<ContactsRoutes.QuickChat> { QuickChatScreen() }
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
QuickChatScreen()
}
}

View file

@ -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<MapRoutes.Map> {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
MapView(
uiViewModel = uiViewModel,
mapViewModel = mapViewModel,

View file

@ -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<Route.DebugPanel> { DebugScreen() }
composable<Route.DebugPanel>(
deepLinks = listOf(navDeepLink<Route.DebugPanel>(basePath = "$DEEP_LINK_BASE_URI/debug_panel")),
) {
DebugScreen()
}
radioConfigGraph(navController, uIViewModel)
}
}

View file

@ -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<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes> {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(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<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail> { backStackEntry ->
composable<NodesRoutes.NodeDetail>(
deepLinks =
listOf(
navDeepLink<NodesRoutes.NodeDetail>( // 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<NodeDetailRoutes.DeviceMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.NodeMap ->
addNodeDetailScreenComposable<NodeDetailRoutes.NodeMap>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PositionLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.EnvironmentMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.SignalMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PowerMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.TracerouteLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.HostMetricsLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PaxMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
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 <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
uiViewModel: UIViewModel,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, passedUiViewModel: UIViewModel) -> Unit,
) {
composable<R>(
deepLinks =
listOf(
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/${routeInfo.name.lowercase()}"),
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/node/${routeInfo.name.lowercase()}"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(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) },
),
}

View file

@ -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<RadioConfigRoutes.RadioConfigGraph>(startDestination = RadioConfigRoutes.RadioConfig()) {
composable<RadioConfigRoutes.RadioConfig> { backStackEntry ->
composable<RadioConfigRoutes.RadioConfig>(
deepLinks =
listOf(navDeepLink<RadioConfigRoutes.RadioConfig>(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<RadioConfigRoutes.CleanNodeDb> { CleanNodeDatabaseScreen() }
configRoutes(navController)
moduleRoutes(navController)
composable<RadioConfigRoutes.CleanNodeDb>(
deepLinks =
listOf(
navDeepLink<RadioConfigRoutes.CleanNodeDb>(
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 <reified R : Route> NavGraphBuilder.addRadioConfigScreenComposable(
navController: NavHostController,
routeNameString: String,
crossinline screenContent: @Composable (viewModel: RadioConfigViewModel) -> Unit,
) {
composable<R>(
deepLinks =
listOf(
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/radio_config/{destNum}/${routeNameString.lowercase()}"),
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/radio_config/${routeNameString.lowercase()}"),
),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(RadioConfigRoutes.RadioConfigGraph::class) }
val viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry)
screenContent(viewModel)
}
}
@Suppress("LongMethod")
private fun NavGraphBuilder.configRoutesScreens(navController: NavHostController) {
ConfigRoute.entries.forEach { entry ->
when (entry.route) {
is RadioConfigRoutes.User ->
addRadioConfigScreenComposable<RadioConfigRoutes.User>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.ChannelConfig ->
addRadioConfigScreenComposable<RadioConfigRoutes.ChannelConfig>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Device ->
addRadioConfigScreenComposable<RadioConfigRoutes.Device>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Position ->
addRadioConfigScreenComposable<RadioConfigRoutes.Position>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Power ->
addRadioConfigScreenComposable<RadioConfigRoutes.Power>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Network ->
addRadioConfigScreenComposable<RadioConfigRoutes.Network>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Display ->
addRadioConfigScreenComposable<RadioConfigRoutes.Display>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.LoRa ->
addRadioConfigScreenComposable<RadioConfigRoutes.LoRa>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Bluetooth ->
addRadioConfigScreenComposable<RadioConfigRoutes.Bluetooth>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Security ->
addRadioConfigScreenComposable<RadioConfigRoutes.Security>(
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<RadioConfigRoutes.MQTT>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Serial ->
addRadioConfigScreenComposable<RadioConfigRoutes.Serial>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.ExtNotification ->
addRadioConfigScreenComposable<RadioConfigRoutes.ExtNotification>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.StoreForward ->
addRadioConfigScreenComposable<RadioConfigRoutes.StoreForward>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.RangeTest ->
addRadioConfigScreenComposable<RadioConfigRoutes.RangeTest>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Telemetry ->
addRadioConfigScreenComposable<RadioConfigRoutes.Telemetry>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.CannedMessage ->
addRadioConfigScreenComposable<RadioConfigRoutes.CannedMessage>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Audio ->
addRadioConfigScreenComposable<RadioConfigRoutes.Audio>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.RemoteHardware ->
addRadioConfigScreenComposable<RadioConfigRoutes.RemoteHardware>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.NeighborInfo ->
addRadioConfigScreenComposable<RadioConfigRoutes.NeighborInfo>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.AmbientLighting ->
addRadioConfigScreenComposable<RadioConfigRoutes.AmbientLighting>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.DetectionSensor ->
addRadioConfigScreenComposable<RadioConfigRoutes.DetectionSensor>(
navController,
entry.name,
entry.screenComposable,
)
is RadioConfigRoutes.Paxcounter ->
addRadioConfigScreenComposable<RadioConfigRoutes.Paxcounter>(
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<ConfigRoute> = 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<ModuleRoute> = entries.filter {
when (metadata) {
null -> true
null -> true // Include all routes if metadata is null
else -> metadata.excludedModules and it.bitfield == 0
}
}