feat: implement unified deep link routing for Kotlin Multiplatform (#4910)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-24 19:21:24 -05:00 committed by GitHub
parent 553ca2f8ed
commit b0e91a390c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 325 additions and 75 deletions

View file

@ -57,11 +57,9 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -71,6 +69,7 @@ fun NodeListScreen(
onNavigateToChannels: () -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@ -100,9 +99,6 @@ fun NodeListScreen(
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
Scaffold(
topBar = {
MainAppBar(
@ -118,14 +114,13 @@ fun NodeListScreen(
},
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
if (!isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable) {
MeshtasticImportFAB(
sharedContact = sharedContact,
onImport = { uriString ->
viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } }
onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,
)
}

View file

@ -20,18 +20,14 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
@ -40,7 +36,6 @@ import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
@Suppress("LongParameterList")
@KoinViewModel
@ -63,12 +58,6 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet = _requestChannelSet.asStateFlow()
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@ -135,24 +124,6 @@ class NodeListViewModel(
nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: SharedContact?) {
_sharedContactRequested.value = sharedContact
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uriString: String, onInvalid: () -> Unit) {
val uri = CommonUri.parse(uriString)
uri.dispatchMeshtasticUri(
onContact = { _sharedContactRequested.value = it },
onChannel = { _requestChannelSet.value = it },
onInvalid = onInvalid,
)
}
fun clearRequestChannelSet() {
_requestChannelSet.value = null
}
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newLoraConfig = channelSet.lora_config

View file

@ -52,6 +52,7 @@ fun AdaptiveNodeListScreen(
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
@ -85,6 +86,7 @@ fun AdaptiveNodeListScreen(
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = activeNodeId,
onHandleDeepLink = onHandleDeepLink,
)
},
detailPane = { contentKey, handleBack ->

View file

@ -67,6 +67,7 @@ import kotlin.reflect.KClass
fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
@ -75,6 +76,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@ -84,16 +86,18 @@ fun EntryProviderScope<NavKey>.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
nodeDetailGraph(backStack, scrollToTopEvents, nodeMapScreen)
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, nodeMapScreen)
}
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
) {
entry<NodesRoutes.NodeDetailGraph> { args ->
@ -103,6 +107,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@ -113,6 +118,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}