From 2c47f7abececbbfa9e3e1c7b2c28f11a48df9275 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 9 Jan 2026 15:14:00 +0100 Subject: [PATCH 01/18] WIP navigation3 feature/template-194-navigation3 --- .idea/copilotDiffState.xml | 18 +++ .../kotlin/nl/q42/template/MainActivity.kt | 46 +++++-- .../nl/q42/template/navigation/HomeEntry.kt | 23 ++++ .../nl/q42/template/navigation/HomeGraph.kt | 42 ------- .../navigation/OnboardingDestinations.kt | 13 +- build.dep.navigation.gradle | 5 + .../q42/template/navigation/Destinations.kt | 9 +- .../viewmodel/AppNavigationState.kt | 39 ++++++ .../navigation/viewmodel/InitNavigator.kt | 48 ++++---- .../navigation/viewmodel/NavigationState.kt | 116 +++++++++++++----- .../navigation/viewmodel/Navigator3.kt | 57 +++++++++ .../navigation/viewmodel/RouteNavigator.kt | 22 ++-- gradle/libs.versions.toml | 17 +++ 13 files changed, 320 insertions(+), 135 deletions(-) create mode 100644 .idea/copilotDiffState.xml create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt delete mode 100644 app/src/main/kotlin/nl/q42/template/navigation/HomeGraph.kt create mode 100644 core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/AppNavigationState.kt create mode 100644 core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml new file mode 100644 index 00000000..ad9d0f89 --- /dev/null +++ b/.idea/copilotDiffState.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/nl/q42/template/MainActivity.kt b/app/src/main/kotlin/nl/q42/template/MainActivity.kt index 690e879a..6e63b66d 100644 --- a/app/src/main/kotlin/nl/q42/template/MainActivity.kt +++ b/app/src/main/kotlin/nl/q42/template/MainActivity.kt @@ -15,13 +15,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay import io.github.aakira.napier.Napier import nl.q42.template.core.utils.config.AppScheme import nl.q42.template.navigation.Destination -import nl.q42.template.navigation.homeGraph -import nl.q42.template.navigation.onboardingDestinations +import nl.q42.template.navigation.homeEntry +import nl.q42.template.navigation.onboardingEntry +import nl.q42.template.navigation.viewmodel.Navigator +import nl.q42.template.navigation.viewmodel.rememberNavigationState +import nl.q42.template.navigation.viewmodel.toEntries import nl.q42.template.ui.compose.composables.widgets.AppSurface import nl.q42.template.ui.compose.composables.window.LocalSnackbarHostState import nl.q42.template.ui.compose.composables.window.toSnackBarVisuals @@ -44,6 +51,23 @@ class MainActivity : ComponentActivity() { setContent { + val navigationState = rememberNavigationState( + startRoute = Destination.Home, + topLevelRoutes = setOf( + // the destinations that can be used to enter the app + Destination.Home, + Destination.Onboarding + ) + ) + + val navigator = remember { Navigator(navigationState) } + val entryProvider: (NavKey) -> NavEntry = entryProvider { + homeEntry(navigator = navigator) + onboardingEntry(navigator = navigator) + } + + + val snackbarHostState = remember { SnackbarHostState() } SnackbarChangedEffect(snackbarHostState) @@ -58,16 +82,12 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), ) { - NavHost( - navController = navController, - startDestination = Destination.HomeGraph - ) { - homeGraph( - navController = navController, - appDeepLinkScheme = appDeepLinkScheme - ) - onboardingDestinations(navController) - } + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + sceneStrategy = remember { DialogSceneStrategy() } + ) + } } } diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt new file mode 100644 index 00000000..0f0ecbc3 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt @@ -0,0 +1,23 @@ +package nl.q42.template.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import nl.q42.template.home.main.presentation.HomeViewModel +import nl.q42.template.home.main.ui.HomeScreen +import nl.q42.template.navigation.viewmodel.InitNavigator +import nl.q42.template.navigation.viewmodel.Navigator +import org.koin.androidx.compose.koinViewModel + +internal fun EntryProviderScope.homeEntry(navigator: Navigator) { + entry { + val viewModel: HomeViewModel = koinViewModel() + InitNavigator(navigator = navigator, routeNavigator = viewModel) + + HomeScreen(viewModel = viewModel) + } + entry { + val viewModel: HomeViewModel = koinViewModel() + InitNavigator(navigator = navigator, routeNavigator = viewModel) + HomeScreen(viewModel = viewModel) + } +} diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeGraph.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeGraph.kt deleted file mode 100644 index 68675b97..00000000 --- a/app/src/main/kotlin/nl/q42/template/navigation/HomeGraph.kt +++ /dev/null @@ -1,42 +0,0 @@ -package nl.q42.template.navigation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import androidx.navigation.navDeepLink -import nl.q42.template.core.utils.config.AppScheme -import nl.q42.template.home.main.presentation.HomeViewModel -import nl.q42.template.home.main.ui.HomeScreen -import nl.q42.template.home.second.presentation.HomeSecondViewModel -import nl.q42.template.home.second.ui.HomeSecondScreen -import nl.q42.template.navigation.viewmodel.InitNavigator -import org.koin.androidx.compose.koinViewModel - -internal fun NavGraphBuilder.homeGraph( - navController: NavHostController, - appDeepLinkScheme: AppScheme, -) { - navigation(startDestination = Destination.Home) { - composable { - - val viewModel: HomeViewModel = koinViewModel() - InitNavigator(navController = navController, routeNavigator = viewModel) - - HomeScreen(viewModel = viewModel) - } - composable( - deepLinks = listOf( - // keep in sync with Destinations.HomeSecond: - // title should be the name of a parameter of Destinations.HomeSecond - navDeepLink { uriPattern = "${appDeepLinkScheme.value}://home/second/{title}" } - ) - ) { - - val viewModel: HomeSecondViewModel = koinViewModel() - InitNavigator(navController = navController, routeNavigator = viewModel) - - HomeSecondScreen(viewModel = viewModel) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt index 66a692f1..27bec2f4 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt @@ -1,18 +1,17 @@ package nl.q42.template.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import nl.q42.template.navigation.viewmodel.InitNavigator +import nl.q42.template.navigation.viewmodel.Navigator import nl.q42.template.onboarding.start.presentation.OnboardingStartViewModel import nl.q42.template.onboarding.start.ui.OnboardingStartScreen import org.koin.androidx.compose.koinViewModel -internal fun NavGraphBuilder.onboardingDestinations(navController: NavHostController) { - composable { - +internal fun EntryProviderScope.onboardingEntry(navigator: Navigator) { + entry { val viewModel: OnboardingStartViewModel = koinViewModel() - InitNavigator(navController = navController, viewModel) + InitNavigator(navigator = navigator, viewModel) OnboardingStartScreen(viewModel = viewModel) } diff --git a/build.dep.navigation.gradle b/build.dep.navigation.gradle index 5d6ff5ab..49f268fd 100644 --- a/build.dep.navigation.gradle +++ b/build.dep.navigation.gradle @@ -1,3 +1,8 @@ dependencies { implementation libs.composeNavigation + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + + // If using the ViewModel add-on library + implementation(libs.androidx.lifecycle.viewmodel.navigation3) } diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt index de206db9..ec10d61a 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt @@ -1,5 +1,6 @@ package nl.q42.template.navigation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable /** @@ -15,15 +16,15 @@ sealed class Destination { * Main destination. If you add a bottom navigation component, make a graph per bottom tab. */ @Serializable - data object HomeGraph : Destination() + data object HomeGraph : Destination(), NavKey @Serializable - data object Home : Destination() + data object Home : Destination(), NavKey @Serializable // all parameters should be path parameters of a deeplink in HomeGraph.kt: composable(deeplinks = listOf(...)) - data class HomeSecond(val title: String) : Destination() + data class HomeSecond(val title: String) : Destination(), NavKey @Serializable - data object Onboarding : Destination() + data object Onboarding : Destination(), NavKey } diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/AppNavigationState.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/AppNavigationState.kt new file mode 100644 index 00000000..7f1c8ec3 --- /dev/null +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/AppNavigationState.kt @@ -0,0 +1,39 @@ +package nl.q42.template.navigation.viewmodel + +import nl.q42.template.navigation.Destination +import java.util.UUID + +sealed class AppNavigationState { + data object Idle : AppNavigationState() + data class NavigateToRoute( + val destination: Destination, + val backstackBehavior: BackstackBehavior, + val id: String = UUID.randomUUID().toString() + ) : AppNavigationState() + + data class PopToDestination(val destination: Destination, val id: String = UUID.randomUUID().toString()) : AppNavigationState() + + data class NavigateUp(val id: String = UUID.randomUUID().toString()) : AppNavigationState() +} + +sealed class BackstackBehavior { + /** + * Adds the destination to the backstack as usual. + */ + data object Default : BackstackBehavior() + + /** + * Removes the current destination from the backstack before navigating. + * + * When navigating A -> B -> C. If B -> C is set to RemoveCurrent, + * the backstack will be A -> C. + */ + data object RemoveCurrent : BackstackBehavior() + + /** + * Clears the backstack and sets the target destination as the backstack's root. + * + * When navigating A -> B -> C. If B -> C is set to Clear, the backstack will be C. + */ + data object Clear : BackstackBehavior() +} diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt index edd5f309..0d506d41 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController +import androidx.navigation3.runtime.NavKey /** * Ensures that [routeNavigator] can navigate on this composition. [routeNavigator] will usually be a ViewModel. @@ -12,54 +12,50 @@ import androidx.navigation.NavHostController * More info: https://medium.com/@ffvanderlaan/navigation-in-jetpack-compose-using-viewmodel-state-3b2517c24dde */ @Composable -fun InitNavigator(navController: NavHostController, routeNavigator: RouteNavigator) { +fun InitNavigator(navigator: Navigator, routeNavigator: RouteNavigator) { - val viewState by routeNavigator.navigationState.collectAsStateWithLifecycle() + val viewState by routeNavigator.appNavigationState.collectAsStateWithLifecycle() LaunchedEffect(viewState) { - updateNavigationState(navController, viewState, routeNavigator::onNavigated) + updateNavigationState(navigator, viewState, routeNavigator::onNavigated) } } /** - * Navigates to [navigationState]. + * Navigates to [appNavigationState]. */ private fun updateNavigationState( - navController: NavHostController, - navigationState: NavigationState, - onNavigated: (navState: NavigationState) -> Unit, + navigator: Navigator, + appNavigationState: AppNavigationState, + onNavigated: (navState: AppNavigationState) -> Unit, ) { - when (navigationState) { - is NavigationState.NavigateToRoute -> { - when (navigationState.backstackBehavior) { + when (appNavigationState) { + is AppNavigationState.NavigateToRoute -> { + when (appNavigationState.backstackBehavior) { BackstackBehavior.Default -> { } BackstackBehavior.RemoveCurrent -> { - navController.popBackStack() + navigator.goBack() } BackstackBehavior.Clear -> { - navController.popBackStack( - navController.graph.id, - false - ) + navigator.clearBackStack() } } - navController.navigate(navigationState.destination) - onNavigated(navigationState) + navigator.navigate(appNavigationState.destination as NavKey) + onNavigated(appNavigationState) } - is NavigationState.PopToDestination -> { - navController.popBackStack(navigationState.destination, false) - onNavigated(navigationState) + is AppNavigationState.PopToDestination -> { + navigator.popToRoute(appNavigationState.destination as NavKey) + onNavigated(appNavigationState) } - is NavigationState.NavigateUp -> { - navController.navigateUp() - onNavigated(navigationState) + is AppNavigationState.NavigateUp -> { + navigator.goBack() } - is NavigationState.Idle -> { + is AppNavigationState.Idle -> { } } -} \ No newline at end of file +} diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/NavigationState.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/NavigationState.kt index 3498e2e5..07897751 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/NavigationState.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/NavigationState.kt @@ -1,39 +1,91 @@ package nl.q42.template.navigation.viewmodel -import nl.q42.template.navigation.Destination -import java.util.UUID +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer -sealed class NavigationState { - data object Idle : NavigationState() - data class NavigateToRoute( - val destination: Destination, - val backstackBehavior: BackstackBehavior, - val id: String = UUID.randomUUID().toString() - ) : NavigationState() +/** + * Create a navigation state that persists config changes and process death. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +): NavigationState { - data class PopToDestination(val destination: Destination, val id: String = UUID.randomUUID().toString()) : NavigationState() + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) + } - data class NavigateUp(val id: String = UUID.randomUUID().toString()) : NavigationState() + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +/** + * State holder for navigation state. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } } -sealed class BackstackBehavior { - /** - * Adds the destination to the backstack as usual. - */ - data object Default : BackstackBehavior() - - /** - * Removes the current destination from the backstack before navigating. - * - * When navigating A -> B -> C. If B -> C is set to RemoveCurrent, - * the backstack will be A -> C. - */ - data object RemoveCurrent : BackstackBehavior() - - /** - * Clears the backstack and sets the target destination as the backstack's root. - * - * When navigating A -> B -> C. If B -> C is set to Clear, the backstack will be C. - */ - data object Clear : BackstackBehavior() -} \ No newline at end of file +/** + * Convert NavigationState into NavEntries. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt new file mode 100644 index 00000000..f9dd95c2 --- /dev/null +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt @@ -0,0 +1,57 @@ +package nl.q42.template.navigation.viewmodel + +import androidx.navigation3.runtime.NavKey +import io.github.aakira.napier.Napier + +/** + * Handles navigation events (forward and back) by updating the navigation state. + */ +class Navigator(val state: NavigationState){ + fun navigate(route: NavKey){ + if (route in state.backStacks.keys){ + // This is a top level route, just switch to it. + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + fun popToRoute(route: NavKey){ + val currentStack = state.backStacks[state.topLevelRoute] + if (currentStack != null) { + val destinationIndex = currentStack.lastIndexOf(route) + if (destinationIndex != -1) { + val elementsToRemove = currentStack.size - 1 - destinationIndex + repeat(elementsToRemove) { + currentStack.removeLastOrNull() + } + } else { + Napier.e { "Route $route not found in the current stack" } + } + } + } + + fun clearBackStack(){ + state.backStacks[state.topLevelRoute]?.clear() + // todo keep top level route? + } + + + fun goBack(){ + val currentStack = state.backStacks[state.topLevelRoute] ?: run { + Napier.e { "Stack for ${state.topLevelRoute} not found" } + null + } + + if (currentStack == null) return + + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute){ + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } +} diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt index 81fb81bd..105967da 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt @@ -9,12 +9,12 @@ import nl.q42.template.navigation.Destination * Navigator to use when initiating navigation from a ViewModel. */ interface RouteNavigator { - fun onNavigated(state: NavigationState) + fun onNavigated(state: AppNavigationState) fun navigateUp() fun popToRoute(destination: Destination) fun navigateTo(destination: Destination, backstackBehavior: BackstackBehavior = BackstackBehavior.Default) - val navigationState: StateFlow + val appNavigationState: StateFlow } class MyRouteNavigator : RouteNavigator { @@ -24,23 +24,23 @@ class MyRouteNavigator : RouteNavigator { * update the state multiple times, the view will only receive and handle the latest state, * which is fine for my use case. */ - override val navigationState: MutableStateFlow = - MutableStateFlow(NavigationState.Idle) + override val appNavigationState: MutableStateFlow = + MutableStateFlow(AppNavigationState.Idle) - override fun onNavigated(state: NavigationState) { + override fun onNavigated(state: AppNavigationState) { // clear navigation state, if state is the current state: - navigationState.compareAndSet(state, NavigationState.Idle) + appNavigationState.compareAndSet(state, AppNavigationState.Idle) } - override fun popToRoute(destination: Destination) = navigate(NavigationState.PopToDestination(destination)) + override fun popToRoute(destination: Destination) = navigate(AppNavigationState.PopToDestination(destination)) - override fun navigateUp() = navigate(NavigationState.NavigateUp()) + override fun navigateUp() = navigate(AppNavigationState.NavigateUp()) override fun navigateTo(destination: Destination, backstackBehavior: BackstackBehavior) = - navigate(NavigationState.NavigateToRoute(destination = destination, backstackBehavior = backstackBehavior)) + navigate(AppNavigationState.NavigateToRoute(destination = destination, backstackBehavior = backstackBehavior)) @VisibleForTesting - fun navigate(state: NavigationState) { - navigationState.value = state + fun navigate(state: AppNavigationState) { + appNavigationState.value = state } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76d1285a..984a98cd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,12 @@ okhttp = "5.2.1" composePlatform = "2025.10.00" activityCompose = "1.11.0" composeLifecycle = "2.9.4" + +# todo remove old nav deps and versions +nav3Core = "1.0.0" +# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library +lifecycleViewmodelNav3 = "2.10.0-rc01" + # Test dependencies kotlinxCoroutinesTest = "1.10.2" junit = "4.13.2" @@ -48,7 +54,18 @@ activityCompose = { module = "androidx.activity:activity-compose", version.ref = composeLifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "composeLifecycle" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } composeStateEvents = { module = "com.github.leonard-palm:compose-state-events", version.ref = "composeStateEvents" } + + composeNavigation = { module = "androidx.navigation:navigation-compose", version.ref = "composeNavigation" } + +# Core Navigation 3 libraries +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } + +# Add-on libraries (only add if you need them) +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } + + koin = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } From e05e8591f01f5d6b5a50f76b03648a22b5ea6c32 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 9 Jan 2026 16:54:50 +0100 Subject: [PATCH 02/18] WIP failed attempt to pass navigation parameters feature/template-194-navigation3 --- .../kotlin/nl/q42/template/navigation/HomeEntry.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt index 0f0ecbc3..bc62c159 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt @@ -4,20 +4,22 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import nl.q42.template.home.main.presentation.HomeViewModel import nl.q42.template.home.main.ui.HomeScreen +import nl.q42.template.home.second.presentation.HomeSecondViewModel +import nl.q42.template.home.second.ui.HomeSecondScreen import nl.q42.template.navigation.viewmodel.InitNavigator import nl.q42.template.navigation.viewmodel.Navigator import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf internal fun EntryProviderScope.homeEntry(navigator: Navigator) { entry { val viewModel: HomeViewModel = koinViewModel() InitNavigator(navigator = navigator, routeNavigator = viewModel) - HomeScreen(viewModel = viewModel) } - entry { - val viewModel: HomeViewModel = koinViewModel() + entry { key -> + val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) } ) // todo this does not work InitNavigator(navigator = navigator, routeNavigator = viewModel) - HomeScreen(viewModel = viewModel) + HomeSecondScreen(viewModel = viewModel) } } From ebcc340b56a93cb2b32b88b26e4fd2be91904bc5 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 9 Jan 2026 17:19:49 +0100 Subject: [PATCH 03/18] FIX passing params to HomeSecond screen feature/template-194-navigation3 --- app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt | 2 +- .../template/home/second/presentation/HomeSecondViewModel.kt | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt index bc62c159..a386e6a5 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt @@ -18,7 +18,7 @@ internal fun EntryProviderScope.homeEntry(navigator: Navigator) { HomeScreen(viewModel = viewModel) } entry { key -> - val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) } ) // todo this does not work + val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) } ) InitNavigator(navigator = navigator, routeNavigator = viewModel) HomeSecondScreen(viewModel = viewModel) } diff --git a/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt b/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt index 33e56a37..bd8fbb70 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt @@ -1,8 +1,6 @@ package nl.q42.template.home.second.presentation -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.navigation.toRoute import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -11,10 +9,9 @@ import nl.q42.template.navigation.viewmodel.RouteNavigator class HomeSecondViewModel( private val navigator: RouteNavigator, - savedStateHandle: SavedStateHandle, + params: Destination.HomeSecond, ) : ViewModel(), RouteNavigator by navigator { - private val params = savedStateHandle.toRoute() private val _uiState = MutableStateFlow(HomeSecondViewState(params.title)) val uiState: StateFlow = _uiState.asStateFlow() From d348d64d498dffb5fea19d714c92b434ef210337 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 9 Jan 2026 17:22:52 +0100 Subject: [PATCH 04/18] ADD key to koinVM parameters even if not necessary Increases consistency feature/template-194-navigation3 --- app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt | 6 +++--- .../nl/q42/template/navigation/OnboardingDestinations.kt | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt index a386e6a5..e14b887d 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt @@ -12,13 +12,13 @@ import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf internal fun EntryProviderScope.homeEntry(navigator: Navigator) { - entry { - val viewModel: HomeViewModel = koinViewModel() + entry { key -> + val viewModel: HomeViewModel = koinViewModel(parameters = { parametersOf(key) }) InitNavigator(navigator = navigator, routeNavigator = viewModel) HomeScreen(viewModel = viewModel) } entry { key -> - val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) } ) + val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) }) InitNavigator(navigator = navigator, routeNavigator = viewModel) HomeSecondScreen(viewModel = viewModel) } diff --git a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt index 27bec2f4..093125ad 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt @@ -7,10 +7,11 @@ import nl.q42.template.navigation.viewmodel.Navigator import nl.q42.template.onboarding.start.presentation.OnboardingStartViewModel import nl.q42.template.onboarding.start.ui.OnboardingStartScreen import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf internal fun EntryProviderScope.onboardingEntry(navigator: Navigator) { - entry { - val viewModel: OnboardingStartViewModel = koinViewModel() + entry { key -> + val viewModel: OnboardingStartViewModel = koinViewModel(parameters = { parametersOf(key) }) InitNavigator(navigator = navigator, viewModel) OnboardingStartScreen(viewModel = viewModel) From ca7423eb94495d1919c4315706bd87acf4a8826b Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 16:27:03 +0100 Subject: [PATCH 05/18] FIX KoinCheckModulesTest feature/template-194-navigation3 --- build.dep.di.gradle | 1 + .../template/home/second/presentation/HomeSecondViewModel.kt | 3 ++- gradle/libs.versions.toml | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build.dep.di.gradle b/build.dep.di.gradle index 770bec8a..b656c6e8 100644 --- a/build.dep.di.gradle +++ b/build.dep.di.gradle @@ -1,3 +1,4 @@ dependencies { implementation(libs.koin) + implementation(libs.koin.annotations) } diff --git a/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt b/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt index bd8fbb70..d96abec9 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/home/second/presentation/HomeSecondViewModel.kt @@ -6,10 +6,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import nl.q42.template.navigation.Destination import nl.q42.template.navigation.viewmodel.RouteNavigator +import org.koin.core.annotation.Provided class HomeSecondViewModel( private val navigator: RouteNavigator, - params: Destination.HomeSecond, + @Provided params: Destination.HomeSecond, ) : ViewModel(), RouteNavigator by navigator { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 984a98cd..3d07cbd4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,10 +28,11 @@ junit = "4.13.2" mockkAndroid = "1.14.6" turbine = "1.2.1" composeStateEvents = "2.2.0" -koin = "4.1.1" +koin = "4.2.0-RC1" [libraries] junit = { module = "junit:junit", version.ref = "junit" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockkAndroid" } From 6a979473ad268094587000bba07b26c20c6fd61c Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 16:59:30 +0100 Subject: [PATCH 06/18] FIX merge conflict feature/template-194-navigation3 --- .../nl/q42/template/navigation/viewmodel/Navigator3.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt index f9dd95c2..5497a05e 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt @@ -1,7 +1,7 @@ package nl.q42.template.navigation.viewmodel import androidx.navigation3.runtime.NavKey -import io.github.aakira.napier.Napier +import co.touchlab.kermit.Logger /** * Handles navigation events (forward and back) by updating the navigation state. @@ -26,7 +26,7 @@ class Navigator(val state: NavigationState){ currentStack.removeLastOrNull() } } else { - Napier.e { "Route $route not found in the current stack" } + Logger.e { "Route $route not found in the current stack" } } } } @@ -39,7 +39,7 @@ class Navigator(val state: NavigationState){ fun goBack(){ val currentStack = state.backStacks[state.topLevelRoute] ?: run { - Napier.e { "Stack for ${state.topLevelRoute} not found" } + Logger.e { "Stack for ${state.topLevelRoute} not found" } null } From cf56233a02cbded04c24df570f3fe8d59d031c35 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 16:59:50 +0100 Subject: [PATCH 07/18] CHANGE use Koin dsl to provide params feature/template-194-navigation3 --- app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt | 4 ++-- .../nl/q42/template/navigation/OnboardingDestinations.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt index e14b887d..eea3db3e 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt @@ -13,12 +13,12 @@ import org.koin.core.parameter.parametersOf internal fun EntryProviderScope.homeEntry(navigator: Navigator) { entry { key -> - val viewModel: HomeViewModel = koinViewModel(parameters = { parametersOf(key) }) + val viewModel: HomeViewModel = koinViewModel { parametersOf(key) } InitNavigator(navigator = navigator, routeNavigator = viewModel) HomeScreen(viewModel = viewModel) } entry { key -> - val viewModel: HomeSecondViewModel = koinViewModel(parameters = { parametersOf(key) }) + val viewModel: HomeSecondViewModel = koinViewModel { parametersOf(key) } InitNavigator(navigator = navigator, routeNavigator = viewModel) HomeSecondScreen(viewModel = viewModel) } diff --git a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt index 093125ad..72ba3e70 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/OnboardingDestinations.kt @@ -11,7 +11,7 @@ import org.koin.core.parameter.parametersOf internal fun EntryProviderScope.onboardingEntry(navigator: Navigator) { entry { key -> - val viewModel: OnboardingStartViewModel = koinViewModel(parameters = { parametersOf(key) }) + val viewModel: OnboardingStartViewModel = koinViewModel { parametersOf(key) } InitNavigator(navigator = navigator, viewModel) OnboardingStartScreen(viewModel = viewModel) From 2922cee3ff8f559f21ed177cea43d948ad76d162 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 17:45:46 +0100 Subject: [PATCH 08/18] REMOVE unused navController feature/template-194-navigation3 --- app/src/main/kotlin/nl/q42/template/MainActivity.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/MainActivity.kt b/app/src/main/kotlin/nl/q42/template/MainActivity.kt index 9c65b99c..ff2232d0 100644 --- a/app/src/main/kotlin/nl/q42/template/MainActivity.kt +++ b/app/src/main/kotlin/nl/q42/template/MainActivity.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -76,8 +75,6 @@ class MainActivity : ComponentActivity() { ) { AppTheme { - val navController = rememberNavController() - AppSurface( modifier = Modifier.fillMaxSize(), ) { From 76642d83c4ea926b0d5573315f61718bd354c34b Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 18:14:49 +0100 Subject: [PATCH 09/18] REMOVE unused AppGraphRoutes.kt feature/template-194-navigation3 --- .../nl/q42/template/navigation/AppGraphRoutes.kt | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 core/navigation/src/main/kotlin/nl/q42/template/navigation/AppGraphRoutes.kt diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/AppGraphRoutes.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/AppGraphRoutes.kt deleted file mode 100644 index c2f7c9dc..00000000 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/AppGraphRoutes.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nl.q42.template.navigation - -/** - * App graph routes are here so all features can navigate to the root of another feature. - */ -object AppGraphRoutes { - const val root = "root" - const val home = "home" - const val onboarding = "onboarding" -} \ No newline at end of file From ea649911bc60aed3a933475bf72e85b70dabfbb7 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 18:15:07 +0100 Subject: [PATCH 10/18] ADD DeepLinkParser.kt for toplevel routes feature/template-194-navigation3 --- .../kotlin/nl/q42/template/MainActivity.kt | 8 +- .../kotlin/nl/q42/template/di/AppModule.kt | 5 +- .../navigation/deeplink/DeepLinkMatcher.kt | 94 +++++++++++++ .../navigation/deeplink/DeepLinkParser.kt | 36 +++++ .../navigation/deeplink/DeepLinkPattern.kt | 129 ++++++++++++++++++ .../navigation/deeplink/DeepLinkRequest.kt | 28 ++++ .../navigation/deeplink/KeyDecoder.kt | 70 ++++++++++ .../q42/template/navigation/Destinations.kt | 13 +- 8 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkParser.kt create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt create mode 100644 app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt diff --git a/app/src/main/kotlin/nl/q42/template/MainActivity.kt b/app/src/main/kotlin/nl/q42/template/MainActivity.kt index ff2232d0..f8ba570c 100644 --- a/app/src/main/kotlin/nl/q42/template/MainActivity.kt +++ b/app/src/main/kotlin/nl/q42/template/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.navigation3.ui.NavDisplay import co.touchlab.kermit.Logger import nl.q42.template.core.utils.config.AppScheme import nl.q42.template.navigation.Destination +import nl.q42.template.navigation.deeplink.DeeplinkParser import nl.q42.template.navigation.homeEntry import nl.q42.template.navigation.onboardingEntry import nl.q42.template.navigation.viewmodel.Navigator @@ -41,6 +42,8 @@ class MainActivity : ComponentActivity() { private val snackbarPresenter: SnackbarPresenter by inject() + private val deeplinkParser: DeeplinkParser by inject() + @OptIn(ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() // must be called before super.onCreate @@ -48,10 +51,13 @@ class MainActivity : ComponentActivity() { Logger.d { "onCreate received, ${intent.data}" } + val startDestination: Destination = deeplinkParser.parseIntent(intent) ?: Destination.Home + Logger.i { "Start destination: $startDestination" } + setContent { val navigationState = rememberNavigationState( - startRoute = Destination.Home, + startRoute = startDestination, topLevelRoutes = setOf( // the destinations that can be used to enter the app Destination.Home, diff --git a/app/src/main/kotlin/nl/q42/template/di/AppModule.kt b/app/src/main/kotlin/nl/q42/template/di/AppModule.kt index 19a4f833..fc9a64c7 100644 --- a/app/src/main/kotlin/nl/q42/template/di/AppModule.kt +++ b/app/src/main/kotlin/nl/q42/template/di/AppModule.kt @@ -5,6 +5,7 @@ import nl.q42.template.core.network.di.networkModule import nl.q42.template.data.main.di.dataModule import nl.q42.template.domain.main.di.domainModule import nl.q42.template.home.di.homeModule +import nl.q42.template.navigation.deeplink.DeeplinkParser import nl.q42.template.navigation.di.navigationModule import nl.q42.template.onboarding.di.onboardingModule import nl.q42.template.ui.di.presentationModule @@ -27,6 +28,8 @@ fun initDependencyInjection(application: MainApplication) { } val appModule = module { + single { DeeplinkParser() } + includes( configModule, @@ -45,4 +48,4 @@ val appModule = module { // domain domainModule ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt new file mode 100644 index 00000000..2ebdca23 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt @@ -0,0 +1,94 @@ +package nl.q42.template.navigation.deeplink + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer +import nl.q42.template.navigation.Destination + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null + if (!request.uri.authority.equals( + deepLinkPattern.uriPattern.authority, + ignoreCase = true + ) + ) return null + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if (requestedSegment != candidateSegment.stringValue) { + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + // If the pattern does not define this query parameter, ignore it. + // This prevents a NullPointerException. + val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach + + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e( + TAG_LOG_ERROR, + "Failed to parse query name:[$name] value:[${query.value}].", + e + ) + return null + } + args[name] = queryParsedValue + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkParser.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkParser.kt new file mode 100644 index 00000000..cfff6ed5 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkParser.kt @@ -0,0 +1,36 @@ +package nl.q42.template.navigation.deeplink + +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import nl.q42.template.navigation.Destination + +internal val deepLinkPatterns: List> = listOf( + DeepLinkPattern( + uriPattern = "template://onboarding".toUri(), + serializer = Destination.Onboarding.serializer() + ) +) + +class DeeplinkParser { + fun parseIntent(intent: Intent): Destination? { + val uri: Uri? = intent.data + // associate the target with the correct backstack key + return uri?.let { + /** STEP 2. Parse requested deeplink */ + val request = DeepLinkRequest(uri) + + /** STEP 3. Compared requested with supported deeplink to find match*/ + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + /** STEP 4. If match is found, associate match to the correct key*/ + match?.let { + //leverage kotlinx.serialization's Decoder to decode + // match result into a backstack key + KeyDecoder(match.args) + .decodeSerializableValue(match.serializer) + } + } + } +} diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt new file mode 100644 index 00000000..303afc89 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt @@ -0,0 +1,129 @@ +package nl.q42.template.navigation.deeplink + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.encoding.CompositeDecoder +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'." + ) + } + + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Query parameter '$paramName' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'." + ) + } + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt new file mode 100644 index 00000000..6e3b00ad --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt @@ -0,0 +1,28 @@ +package nl.q42.template.navigation.deeplink + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt new file mode 100644 index 00000000..139c8c26 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt @@ -0,0 +1,70 @@ +package nl.q42.template.navigation.deeplink + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt index ec10d61a..ab74d878 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/Destinations.kt @@ -6,25 +6,24 @@ import kotlinx.serialization.Serializable /** * All destinations that can be navigated to. Use these in your ViewModel, whenever you * want to navigate. Note that you can only navigate to a destination from the correct graph, - * see [nl.q42.template.navigation.homeGraph]. - * For deeplink support, add a deep link to the destination in the graph. + * For deeplink support, add a deep link to the AndroidManifest.xml and an entry to [nl.q42.template.navigation.deeplink.DeeplinkParser]. */ @Serializable -sealed class Destination { +sealed class Destination: NavKey { /** * Main destination. If you add a bottom navigation component, make a graph per bottom tab. */ @Serializable - data object HomeGraph : Destination(), NavKey + data object HomeGraph : Destination() @Serializable - data object Home : Destination(), NavKey + data object Home : Destination() @Serializable // all parameters should be path parameters of a deeplink in HomeGraph.kt: composable(deeplinks = listOf(...)) - data class HomeSecond(val title: String) : Destination(), NavKey + data class HomeSecond(val title: String) : Destination() @Serializable - data object Onboarding : Destination(), NavKey + data object Onboarding : Destination() } From 7ee9c41db0f06d3f55c1fb95bdd30fbe151bf5e2 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 19:17:46 +0100 Subject: [PATCH 11/18] FIX could not go to Onboarding a second time feature/template-194-navigation3 --- .../navigation/viewmodel/Navigator3.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt index 5497a05e..df027617 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt @@ -6,17 +6,20 @@ import co.touchlab.kermit.Logger /** * Handles navigation events (forward and back) by updating the navigation state. */ -class Navigator(val state: NavigationState){ - fun navigate(route: NavKey){ - if (route in state.backStacks.keys){ - // This is a top level route, just switch to it. +class Navigator(val state: NavigationState) { + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + // This is a top level route: switch, and make sure it is not empty state.topLevelRoute = route + if (state.backStacks[route]?.isEmpty() == true) { + state.backStacks[route]?.add(route) + } } else { state.backStacks[state.topLevelRoute]?.add(route) } } - fun popToRoute(route: NavKey){ + fun popToRoute(route: NavKey) { val currentStack = state.backStacks[state.topLevelRoute] if (currentStack != null) { val destinationIndex = currentStack.lastIndexOf(route) @@ -31,13 +34,13 @@ class Navigator(val state: NavigationState){ } } - fun clearBackStack(){ + fun clearBackStack() { state.backStacks[state.topLevelRoute]?.clear() // todo keep top level route? } - fun goBack(){ + fun goBack() { val currentStack = state.backStacks[state.topLevelRoute] ?: run { Logger.e { "Stack for ${state.topLevelRoute} not found" } null @@ -48,7 +51,7 @@ class Navigator(val state: NavigationState){ val currentRoute = currentStack.last() // If we're at the base of the current route, go back to the start route stack. - if (currentRoute == state.topLevelRoute){ + if (currentRoute == state.topLevelRoute) { state.topLevelRoute = state.startRoute } else { currentStack.removeLastOrNull() From 14fd165ce14b9f6edb39295d24ca8c1c41d3d9da Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 20:21:41 +0100 Subject: [PATCH 12/18] ADD attribution to deeplink files feature/template-194-navigation3 --- app/src/main/kotlin/nl/q42/template/MainActivity.kt | 7 +------ .../nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt | 4 ++++ .../nl/q42/template/navigation/deeplink/DeepLinkPattern.kt | 2 ++ .../nl/q42/template/navigation/deeplink/DeepLinkRequest.kt | 2 -- .../nl/q42/template/navigation/deeplink/KeyDecoder.kt | 4 ++++ 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/nl/q42/template/MainActivity.kt b/app/src/main/kotlin/nl/q42/template/MainActivity.kt index f8ba570c..9df3fcb2 100644 --- a/app/src/main/kotlin/nl/q42/template/MainActivity.kt +++ b/app/src/main/kotlin/nl/q42/template/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.ui.NavDisplay import co.touchlab.kermit.Logger -import nl.q42.template.core.utils.config.AppScheme import nl.q42.template.navigation.Destination import nl.q42.template.navigation.deeplink.DeeplinkParser import nl.q42.template.navigation.homeEntry @@ -38,8 +37,6 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { - private val appDeepLinkScheme: AppScheme by inject() - private val snackbarPresenter: SnackbarPresenter by inject() private val deeplinkParser: DeeplinkParser by inject() @@ -59,7 +56,7 @@ class MainActivity : ComponentActivity() { val navigationState = rememberNavigationState( startRoute = startDestination, topLevelRoutes = setOf( - // the destinations that can be used to enter the app + // the destinations that can be used to enter the app, typically the tabs in the bottom navigation bar. Destination.Home, Destination.Onboarding ) @@ -71,8 +68,6 @@ class MainActivity : ComponentActivity() { onboardingEntry(navigator = navigator) } - - val snackbarHostState = remember { SnackbarHostState() } SnackbarChangedEffect(snackbarHostState) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt index 2ebdca23..8bdc1be5 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt @@ -1,5 +1,9 @@ package nl.q42.template.navigation.deeplink +/** + * Source: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/deeplink/basic/util/DeepLinkMatcher.kt + */ + import android.util.Log import androidx.navigation3.runtime.NavKey import kotlinx.serialization.KSerializer diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt index 303afc89..23629e3f 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt @@ -33,6 +33,8 @@ import java.io.Serializable * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match * 2. all query arguments are optional by way of nullable/has default value * + * Source: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/deeplink/basic/util/DeepLinkPattern.kt + * * @param T the backstack key type that supports the deeplinking of [uriPattern] * @param serializer the serializer of [T] * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt index 6e3b00ad..13ccc7e2 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt @@ -23,6 +23,4 @@ internal class DeepLinkRequest( this[argName] = uri.getQueryParameter(argName)!! } } - - // TODO add parsing for other Uri components, i.e. fragments, mimeType, action } diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt index 139c8c26..22c3657a 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt @@ -1,5 +1,9 @@ package nl.q42.template.navigation.deeplink +/** + * Source: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/deeplink/basic/util/DeepLinkRequest.kt + */ + import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.AbstractDecoder From 1bf686e5f6e88ced42ef18f14359d34a41ed81b6 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 13 Feb 2026 20:25:06 +0100 Subject: [PATCH 13/18] REMOVE unused navigation libs feature/template-194-navigation3 --- app/build.gradle | 1 - build.dep.navigation.gradle | 4 ---- gradle/libs.versions.toml | 11 ----------- 3 files changed, 16 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5367cb5b..41b61dec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,7 +90,6 @@ dependencies { implementation(project(":domain:main")) // needed for di implementation(project(":data:main")) // needed for di implementation(project(":core:network")) // needed for di - implementation(libs.composeNavigation) api platform(libs.firebaseBoM) implementation(libs.firebaseCrashlytics) diff --git a/build.dep.navigation.gradle b/build.dep.navigation.gradle index 49f268fd..2d41d0b2 100644 --- a/build.dep.navigation.gradle +++ b/build.dep.navigation.gradle @@ -1,8 +1,4 @@ dependencies { - implementation libs.composeNavigation implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) - - // If using the ViewModel add-on library - implementation(libs.androidx.lifecycle.viewmodel.navigation3) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc894a71..a27ee03a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,16 +11,12 @@ retrofit = "3.0.0" kotlinx-serialization = "1.9.0" retrofit2KotlinxSerializationConverter = "1.0.0" networkResponseAdapter = "5.0.0" -composeNavigation = "2.9.5" okhttp = "5.2.1" composePlatform = "2025.10.00" activityCompose = "1.11.0" composeLifecycle = "2.9.4" -# todo remove old nav deps and versions nav3Core = "1.0.0" -# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library -lifecycleViewmodelNav3 = "2.10.0-rc01" # Test dependencies kotlinxCoroutinesTest = "1.10.2" @@ -57,17 +53,10 @@ composeLifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", ve turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } composeStateEvents = { module = "com.github.leonard-palm:compose-state-events", version.ref = "composeStateEvents" } - -composeNavigation = { module = "androidx.navigation:navigation-compose", version.ref = "composeNavigation" } - # Core Navigation 3 libraries androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } -# Add-on libraries (only add if you need them) -androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } - - koin = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } From 8ad14afde38a94bab2be062cde0a6a4ab353270d Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Sat, 14 Feb 2026 13:37:03 +0100 Subject: [PATCH 14/18] FIX properly navigate from Onboarding to Home, clearing backstack feature/template-194-navigation3 --- .../onboarding/start/presentation/OnboardingStartViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/onboarding/src/main/kotlin/nl/q42/template/onboarding/start/presentation/OnboardingStartViewModel.kt b/feature/onboarding/src/main/kotlin/nl/q42/template/onboarding/start/presentation/OnboardingStartViewModel.kt index a3f6cf57..8f0298b2 100644 --- a/feature/onboarding/src/main/kotlin/nl/q42/template/onboarding/start/presentation/OnboardingStartViewModel.kt +++ b/feature/onboarding/src/main/kotlin/nl/q42/template/onboarding/start/presentation/OnboardingStartViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import nl.q42.template.navigation.Destination +import nl.q42.template.navigation.viewmodel.BackstackBehavior import nl.q42.template.navigation.viewmodel.RouteNavigator class OnboardingStartViewModel( @@ -14,6 +16,6 @@ class OnboardingStartViewModel( val uiState: StateFlow = _uiState.asStateFlow() fun onBackClicked() { - navigateUp() + navigateTo(Destination.Home, backstackBehavior = BackstackBehavior.Clear) } } From c4a165b2a1e2b08d917acd1672e34d165607db67 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Mar 2026 12:01:31 +0100 Subject: [PATCH 15/18] Update core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt index 0d506d41..61beacd4 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/InitNavigator.kt @@ -53,6 +53,7 @@ private fun updateNavigationState( is AppNavigationState.NavigateUp -> { navigator.goBack() + onNavigated(appNavigationState) } is AppNavigationState.Idle -> { From 3f7390dfe71066459d1decffef0e8c64da1f555c Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Mar 2026 12:03:08 +0100 Subject: [PATCH 16/18] Update app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../nl/q42/template/navigation/deeplink/DeepLinkPattern.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt index 23629e3f..9ebbc7a3 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt @@ -119,7 +119,7 @@ private fun getTypeParser(kind: SerialKind): TypeParser { PrimitiveKind.INT -> String::toInt PrimitiveKind.BOOLEAN -> String::toBoolean PrimitiveKind.BYTE -> String::toByte - PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.CHAR -> { it.single() } PrimitiveKind.DOUBLE -> String::toDouble PrimitiveKind.FLOAT -> String::toFloat PrimitiveKind.LONG -> String::toLong From 50cf9aa8d890ee2811a742b633893401c0058f0a Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Mar 2026 12:11:31 +0100 Subject: [PATCH 17/18] FIX deeplink char parsing feature/template-194-navigation3 --- .../nl/q42/template/navigation/deeplink/DeepLinkPattern.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt index 9ebbc7a3..37d65fdc 100644 --- a/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt +++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt @@ -119,7 +119,7 @@ private fun getTypeParser(kind: SerialKind): TypeParser { PrimitiveKind.INT -> String::toInt PrimitiveKind.BOOLEAN -> String::toBoolean PrimitiveKind.BYTE -> String::toByte - PrimitiveKind.CHAR -> { it.single() } + PrimitiveKind.CHAR -> String::single PrimitiveKind.DOUBLE -> String::toDouble PrimitiveKind.FLOAT -> String::toFloat PrimitiveKind.LONG -> String::toLong From 785f39acc91acb5ad6a05c92fb3850fe6631eac3 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Mar 2026 12:13:09 +0100 Subject: [PATCH 18/18] REMOVE copilotDiffState.xml feature/template-194-navigation3 --- .gitignore | 1 + .idea/copilotDiffState.xml | 18 ------------------ 2 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 .idea/copilotDiffState.xml diff --git a/.gitignore b/.gitignore index 8abdcdd7..d760e761 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /local.properties /.idea/caches /.idea/libraries +/.idea/copilotDiffState.xml /.idea/modules.xml /.idea/deploymentTargetSelector.xml /.idea/workspace.xml diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml deleted file mode 100644 index ad9d0f89..00000000 --- a/.idea/copilotDiffState.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file