refactor: migrate core UI and features to KMP, adopt Navigation 3 (#4750)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-10 12:29:47 -05:00 committed by GitHub
parent b1070321fe
commit d076361c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 3106 additions and 1748 deletions

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 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 org.meshtastic.app
import androidx.test.runner.AndroidJUnitRunner
@Suppress("unused")
class TestRunner : AndroidJUnitRunner()

View file

@ -33,6 +33,7 @@ class MessageFilterIntegrationTest : KoinTest {
private val filterService: MessageFilter by inject()
@org.junit.Ignore("Flaky integration test, needs Koin test rule setup")
@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.setFilterEnabled(true)

View file

@ -17,7 +17,6 @@
package org.meshtastic.app.map
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -25,7 +24,6 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@ -46,7 +44,7 @@ class MapViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
var mapStyleId: Int

View file

@ -21,7 +21,6 @@ import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
@ -48,7 +47,6 @@ import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@ -90,7 +88,7 @@ class MapViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
private val targetLatLng =

View file

@ -18,7 +18,6 @@ package org.meshtastic.app.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.distinctUntilChanged
@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.toList
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@ -46,7 +44,7 @@ class NodeMapViewModel(
buildConfigProvider: BuildConfigProvider,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
private val destNum = savedStateHandle.get<Int>("destNum") ?: 0
val node =
nodeRepository.nodeDBbyNum

View file

@ -16,41 +16,29 @@
*/
package org.meshtastic.app.navigation
import androidx.compose.runtime.remember
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.ui.sharing.ChannelScreen
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
fun NavGraphBuilder.channelsGraph(navController: NavHostController) {
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(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
onNavigate = { route -> navController.navigate(route) },
onNavigateUp = { navController.navigateUp() },
)
}
fun EntryProviderScope<NavKey>.channelsGraph(backStack: NavBackStack<NavKey>) {
entry<ChannelsRoutes.ChannelsGraph> {
ChannelScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)
}
navController.configComposable<SettingsRoutes.ChannelConfig, ChannelsRoutes.ChannelsGraph> {
ChannelConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
navController.configComposable<SettingsRoutes.LoRa, ChannelsRoutes.ChannelsGraph> {
LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
entry<ChannelsRoutes.Channels> {
ChannelScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)
}
}

View file

@ -16,47 +16,35 @@
*/
package org.meshtastic.app.navigation
import androidx.compose.runtime.remember
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.ui.connections.ConnectionsScreen
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {
@Suppress("ktlint:standard:max-line-length")
navigation<ConnectionsRoutes.ConnectionsGraph>(startDestination = ConnectionsRoutes.Connections) {
composable<ConnectionsRoutes.Connections>(
deepLinks = listOf(
navDeepLink<ConnectionsRoutes.Connections>(basePath = "$DEEP_LINK_BASE_URI/connections"),
),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(ConnectionsRoutes.ConnectionsGraph) }
ConnectionsScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> navController.navigate(route) },
)
}
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = {
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
backStack.add(NodesRoutes.NodeDetailGraph(it))
},
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
navController.configComposable<SettingsRoutes.LoRa, ConnectionsRoutes.ConnectionsGraph> {
LoRaConfigScreen(viewModel = it, onBack = navController::popBackStack)
}
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
}

View file

@ -18,12 +18,9 @@ package org.meshtastic.app.navigation
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.messaging.AndroidContactsViewModel
@ -31,91 +28,94 @@ import org.meshtastic.app.messaging.AndroidMessageViewModel
import org.meshtastic.app.messaging.AndroidQuickChatViewModel
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) {
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
) {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
fun EntryProviderScope<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
) {
entry<ContactsRoutes.ContactsGraph> {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
}
composable<ContactsRoutes.Messages>(
deepLinks =
listOf(
navDeepLink<ContactsRoutes.Messages>(
basePath =
"$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended
),
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = args.contactKey,
initialMessage = args.message,
)
}
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
}
composable<ContactsRoutes.Share>(
deepLinks =
listOf(
navDeepLink<ContactsRoutes.Share>(
basePath = "$DEEP_LINK_BASE_URI/share", // ?message={message} is auto-appended
),
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
entry<ContactsRoutes.Contacts> {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
}
entry<ContactsRoutes.Messages> { args ->
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<AndroidContactsViewModel>()
val messageViewModel = koinViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = args.contactKey,
initialMessage = args.message,
)
}
entry<ContactsRoutes.Share> { args ->
val message = args.message
val viewModel = koinViewModel<AndroidContactsViewModel>()
ShareScreen(
viewModel = viewModel,
onConfirm = {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
}
// Navigation 3 - replace Top with Messages manually, but for now we just pop and add
backStack.removeLastOrNull()
backStack.add(ContactsRoutes.Messages(it, message))
},
onNavigateUp = navController::navigateUp,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
entry<ContactsRoutes.QuickChat> {
val viewModel = koinViewModel<AndroidQuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = navController::navigateUp)
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}

View file

@ -16,20 +16,17 @@
*/
package org.meshtastic.app.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.firmware.AndroidFirmwareUpdateViewModel
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
fun NavGraphBuilder.firmwareGraph(navController: NavController) {
navigation<FirmwareRoutes.FirmwareGraph>(startDestination = FirmwareRoutes.FirmwareUpdate) {
composable<FirmwareRoutes.FirmwareUpdate> {
val viewModel = koinViewModel<AndroidFirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = { navController.navigateUp() }, viewModel = viewModel)
}
fun EntryProviderScope<NavKey>.firmwareGraph(backStack: NavBackStack<NavKey>) {
entry<FirmwareRoutes.FirmwareUpdate> {
val viewModel = koinViewModel<AndroidFirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
}
}

View file

@ -16,29 +16,22 @@
*/
package org.meshtastic.app.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.AndroidSharedMapViewModel
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.map.MapScreen
fun NavGraphBuilder.mapGraph(navController: NavHostController) {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoutes.Map> {
val viewModel = koinViewModel<AndroidSharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
)
}
}

View file

@ -27,16 +27,10 @@ import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material.icons.rounded.Power
import androidx.compose.material.icons.rounded.Router
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
@ -45,7 +39,6 @@ import org.meshtastic.app.map.node.NodeMapViewModel
import org.meshtastic.app.node.AndroidMetricsViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
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
@ -73,220 +66,121 @@ import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
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")),
) {
AdaptiveNodeListScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
nodeDetailGraph(navController, scrollToTopEvents)
fun EntryProviderScope<NavKey>.nodesGraph(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
entry<NodesRoutes.NodesGraph> {
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
entry<NodesRoutes.Nodes> {
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
nodeDetailGraph(backStack, scrollToTopEvents)
}
@Suppress("LongMethod")
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 =
listOf(
navDeepLink<NodesRoutes.NodeDetail>( // Handles both /node and /node/{destNum} due to destNum: Int?
basePath = "$DEEP_LINK_BASE_URI/node",
),
),
) { backStackEntry ->
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)) },
)
}
fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
) {
entry<NodesRoutes.NodeDetailGraph> { args ->
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
composable<NodeDetailRoutes.NodeMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/node_map"),
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/node_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val vm = koinViewModel<NodeMapViewModel>(viewModelStoreOwner = parentGraphBackStackEntry)
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
entry<NodesRoutes.NodeDetail> { args ->
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
composable<NodeDetailRoutes.TracerouteLog>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteLog>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
),
navDeepLink<NodeDetailRoutes.TracerouteLog>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel =
koinViewModel<AndroidMetricsViewModel>(viewModelStoreOwner = parentGraphBackStackEntry)
entry<NodeDetailRoutes.NodeMap> { args ->
val vm = koinViewModel<NodeMapViewModel>()
NodeMapScreen(vm, onNavigateUp = { backStack.removeLastOrNull() })
}
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteLog>()
metricsViewModel.setNodeId(args.destNum)
entry<NodeDetailRoutes.TracerouteLog> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = navController::navigateUp,
onViewOnMap = { requestId, responseLogUuid ->
navController.navigate(
NodeDetailRoutes.TracerouteMap(
destNum = args.destNum,
requestId = requestId,
logUuid = responseLogUuid,
),
)
},
)
}
TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = { backStack.removeLastOrNull() },
onViewOnMap = { requestId, responseLogUuid ->
backStack.add(
NodeDetailRoutes.TracerouteMap(
destNum = args.destNum,
requestId = requestId,
logUuid = responseLogUuid,
),
)
},
)
}
composable<NodeDetailRoutes.TracerouteMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteMap>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
),
navDeepLink<NodeDetailRoutes.TracerouteMap>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel =
koinViewModel<AndroidMetricsViewModel>(viewModelStoreOwner = parentGraphBackStackEntry)
entry<NodeDetailRoutes.TracerouteMap> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
metricsViewModel.setNodeId(args.destNum)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteMap>()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = navController::navigateUp,
)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PositionLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.EnvironmentMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.SignalMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PowerMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.PaxMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
else -> Unit
}
NodeDetailRoute.entries.forEach { routeInfo ->
when (routeInfo.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.PositionLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.EnvironmentMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.SignalMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.PowerMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.PaxMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoutes.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(backStack, routeInfo) { it.destNum }
else -> Unit
}
}
}
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
/**
* Helper to define a composable route for a screen within the node detail 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.
* @param getDestNum A lambda to extract the destination number from the route arguments.
*/
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
backStack: NavBackStack<NavKey>,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
crossinline getDestNum: (R) -> Int,
) {
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 = koinViewModel<AndroidMetricsViewModel>(viewModelStoreOwner = parentGraphBackStackEntry)
val args = backStackEntry.toRoute<R>()
entry<R> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum)
screenContent(metricsViewModel, navController::navigateUp)
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }
}
}

View file

@ -21,21 +21,16 @@ package org.meshtastic.app.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidCleanNodeDatabaseViewModel
import org.meshtastic.app.settings.AndroidDebugViewModel
import org.meshtastic.app.settings.AndroidFilterSettingsViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.settings.AndroidSettingsViewModel
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.Graph
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@ -77,185 +72,132 @@ import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigSc
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@Suppress("LongMethod")
fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
navigation<SettingsRoutes.SettingsGraph>(startDestination = SettingsRoutes.Settings()) {
composable<SettingsRoutes.Settings>(
deepLinks = listOf(navDeepLink<SettingsRoutes.Settings>(basePath = "$DEEP_LINK_BASE_URI/settings")),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(viewModelStoreOwner = parentEntry),
viewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
) {
navController.navigate(it)
}
}
composable<SettingsRoutes.DeviceConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
DeviceConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}
composable<SettingsRoutes.ModuleConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
val settingsViewModel: AndroidSettingsViewModel = koinViewModel(viewModelStoreOwner = parentEntry)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}
composable<SettingsRoutes.Administration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
AdministrationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry),
onBack = navController::popBackStack,
)
}
composable<SettingsRoutes.CleanNodeDb>(
deepLinks =
listOf(
navDeepLink<SettingsRoutes.CleanNodeDb>(
basePath = "$DEEP_LINK_BASE_URI/settings/radio/clean_node_db",
),
),
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
val viewModel: AndroidCleanNodeDatabaseViewModel = koinViewModel()
CleanNodeDatabaseScreen(viewModel = viewModel)
backStack.add(it)
}
}
ConfigRoute.entries.forEach { entry ->
navController.configComposable(
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.DEVICE -> DeviceConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.POSITION -> PositionConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = navController::popBackStack)
ConfigRoute.SECURITY -> SecurityConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
ModuleRoute.entries.forEach { entry ->
navController.configComposable(
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.EXT_NOTIFICATION ->
ExternalNotificationConfigScreen(viewModel = viewModel, onBack = navController::popBackStack)
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.CANNED_MESSAGE ->
CannedMessageConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.REMOTE_HARDWARE ->
RemoteHardwareConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.NEIGHBOR_INFO ->
NeighborInfoConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.AMBIENT_LIGHTING ->
AmbientLightingConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.DETECTION_SENSOR ->
DetectionSensorConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TRAFFIC_MANAGEMENT ->
TrafficManagementConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
composable<SettingsRoutes.DebugPanel>(
deepLinks =
listOf(navDeepLink<SettingsRoutes.DebugPanel>(basePath = "$DEEP_LINK_BASE_URI/settings/debug_panel")),
entry<SettingsRoutes.Settings> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
val viewModel: AndroidDebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = navController::navigateUp)
backStack.add(it)
}
}
composable<SettingsRoutes.About> { AboutScreen(onNavigateUp = navController::navigateUp) }
entry<SettingsRoutes.DeviceConfiguration> {
DeviceConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
composable<SettingsRoutes.FilterSettings> {
val viewModel: AndroidFilterSettingsViewModel = koinViewModel()
FilterSettingsScreen(viewModel = viewModel, onBack = navController::navigateUp)
entry<SettingsRoutes.ModuleConfiguration> {
val settingsViewModel: AndroidSettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
entry<SettingsRoutes.Administration> {
AdministrationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onBack = { backStack.removeLastOrNull() },
)
}
entry<SettingsRoutes.CleanNodeDb> {
val viewModel: AndroidCleanNodeDatabaseViewModel = koinViewModel()
CleanNodeDatabaseScreen(viewModel = viewModel)
}
ConfigRoute.entries.forEach { routeInfo ->
configComposable(routeInfo.route::class) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DEVICE -> DeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POSITION -> PositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.SECURITY -> SecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
ModuleRoute.entries.forEach { routeInfo ->
configComposable(routeInfo.route::class) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.EXT_NOTIFICATION ->
ExternalNotificationConfigScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.CANNED_MESSAGE ->
CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.REMOTE_HARDWARE ->
RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.NEIGHBOR_INFO ->
NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AMBIENT_LIGHTING ->
AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.DETECTION_SENSOR ->
DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TRAFFIC_MANAGEMENT ->
TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
entry<SettingsRoutes.DebugPanel> {
val viewModel: AndroidDebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
entry<SettingsRoutes.About> { AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
entry<SettingsRoutes.FilterSettings> {
val viewModel: AndroidFilterSettingsViewModel = koinViewModel()
FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
}
}
context(_: NavGraphBuilder)
inline fun <reified R : Route, reified G : Graph> NavHostController.configComposable(
noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit,
) {
configComposable(route = R::class, parentGraphRoute = G::class, content = content)
}
context(navGraphBuilder: NavGraphBuilder)
fun <R : Route, G : Graph> NavHostController.configComposable(
fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
parentGraphRoute: KClass<G>,
content: @Composable (AndroidRadioConfigViewModel) -> Unit,
) {
navGraphBuilder.composable(route = route) { backStackEntry ->
val parentEntry = remember(backStackEntry) { getBackStackEntry(parentGraphRoute) }
content(koinViewModel<AndroidRadioConfigViewModel>(viewModelStoreOwner = parentEntry))
}
addEntryProvider(route) { content(koinViewModel<AndroidRadioConfigViewModel>()) }
}
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit,
) {
entry<R> { content(koinViewModel<AndroidRadioConfigViewModel>()) }
}

View file

@ -20,7 +20,6 @@ import android.app.Application
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -29,7 +28,6 @@ import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@ -56,7 +54,7 @@ class AndroidMetricsViewModel(
alertManager: AlertManager,
getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : MetricsViewModel(
savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum ?: 0,
savedStateHandle.get<Int>("destNum") ?: 0,
dispatchers,
meshLogRepository,
serviceRepository,

View file

@ -68,13 +68,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -150,8 +147,8 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
;
companion object {
fun fromNavDestination(destination: NavDestination?): TopLevelDestination? =
entries.find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true }
fun fromNavKey(key: NavKey?): TopLevelDestination? =
entries.find { dest -> key?.let { it::class == dest.route::class } == true }
}
}
@ -159,8 +156,9 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
val navController = rememberNavController()
LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } }
val backStack = rememberNavBackStack(NodesRoutes.NodesGraph as NavKey)
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
// }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
@ -230,7 +228,7 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
val errorRes = availability.toMessageRes()
if (errorRes == null) {
dismissedTracerouteRequestId = response.requestId
navController.navigate(
backStack.add(
NodeDetailRoutes.TracerouteMap(
destNum = response.destinationNodeNum,
requestId = response.requestId,
@ -250,8 +248,8 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
val currentKey = backStack.lastOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(currentKey)
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
@ -405,52 +403,47 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentDestination?.hasRoute(NodesRoutes.Nodes::class) == true
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
backStack.clear()
backStack.add(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList =
currentDestination?.hasRoute(ContactsRoutes.Contacts::class) == true
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
backStack.clear()
backStack.add(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
backStack.clear()
backStack.add(destination.route)
}
},
)
}
},
) {
NavHost(
navController = navController,
startDestination = NodesRoutes.NodesGraph,
val provider =
entryProvider<NavKey> {
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
nodesGraph(backStack, uIViewModel.scrollToTopEventFlow)
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
}
NavDisplay(
backStack = backStack,
entryProvider = provider,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
) {
contactsGraph(navController, uIViewModel.scrollToTopEventFlow)
nodesGraph(navController, uIViewModel.scrollToTopEventFlow)
mapGraph(navController)
channelsGraph(navController)
connectionsGraph(navController)
settingsGraph(navController)
firmwareGraph(navController)
}
)
}
}

View file

@ -43,8 +43,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -66,7 +66,7 @@ import org.meshtastic.feature.node.list.NodeListScreen
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveNodeListScreen(
navController: NavHostController,
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigateToMessages: (String) -> Unit = {},
@ -77,16 +77,14 @@ fun AdaptiveNodeListScreen(
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
val handleBack: () -> Unit = {
val currentEntry = navController.currentBackStackEntry
val isNodesRoute = currentEntry?.destination?.hasRoute<NodesRoutes.Nodes>() == true
// Check if we navigated here from another screen (e.g., from Messages or Map)
val previousEntry = navController.previousBackStackEntry
val isFromDifferentGraph = previousEntry?.destination?.hasRoute<NodesRoutes.NodesGraph>() == false
val currentKey = backStack.lastOrNull()
val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
val isFromDifferentGraph = previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
if (isFromDifferentGraph && !isNodesRoute) {
// Navigate back via NavController to return to the previous screen
navController.navigateUp()
backStack.removeLastOrNull()
} else {
// Close the detail pane within the adaptive scaffold
scope.launch { navigator.navigateBack(backNavigationBehavior) }
@ -129,7 +127,7 @@ fun AdaptiveNodeListScreen(
navigateToNodeDetails = { nodeId ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
},
onNavigateToChannels = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = navigator.currentDestination?.contentKey,
)
@ -149,7 +147,7 @@ fun AdaptiveNodeListScreen(
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = onNavigateToMessages,
onNavigate = { route -> navController.navigate(route) },
onNavigate = { route -> backStack.add(route) },
onNavigateUp = handleBack,
)
}

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.app.ui.sharing
import android.net.Uri
import android.os.RemoteException
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -69,11 +68,9 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
@ -96,6 +93,7 @@ import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.core.ui.component.QrDialog
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.generateQrCode
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
@ -299,13 +297,17 @@ fun ChannelScreen(
}
}
private const val QR_CODE_SIZE = 960
@Composable
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
val commonUri = channelSet.getChannelUrl(shouldAddChannel)
val uriString = commonUri.toString()
val qrCode = remember(uriString) { generateQrCode(uriString, QR_CODE_SIZE) }
QrDialog(
title = stringResource(Res.string.share_channels_qr),
uri = commonUri.toPlatformUri() as Uri,
qrCode = channelSet.qrCode(shouldAddChannel),
uriString = uriString,
qrCode = qrCode,
onDismiss = onDismiss,
)
}