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/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/app/src/main/kotlin/nl/q42/template/MainActivity.kt b/app/src/main/kotlin/nl/q42/template/MainActivity.kt
index 1b33dd12..9df3fcb2 100644
--- a/app/src/main/kotlin/nl/q42/template/MainActivity.kt
+++ b/app/src/main/kotlin/nl/q42/template/MainActivity.kt
@@ -15,13 +15,19 @@ 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 co.touchlab.kermit.Logger
-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.deeplink.DeeplinkParser
+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
@@ -31,10 +37,10 @@ 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()
+
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // must be called before super.onCreate
@@ -42,8 +48,26 @@ 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 = startDestination,
+ topLevelRoutes = setOf(
+ // the destinations that can be used to enter the app, typically the tabs in the bottom navigation bar.
+ 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)
@@ -52,22 +76,16 @@ class MainActivity : ComponentActivity() {
) {
AppTheme {
- val navController = rememberNavController()
-
AppSurface(
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/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/HomeEntry.kt b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt
new file mode 100644
index 00000000..eea3db3e
--- /dev/null
+++ b/app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt
@@ -0,0 +1,25 @@
+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.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 { key ->
+ val viewModel: HomeViewModel = koinViewModel { parametersOf(key) }
+ InitNavigator(navigator = navigator, routeNavigator = viewModel)
+ HomeScreen(viewModel = viewModel)
+ }
+ entry { 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/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..72ba3e70 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,18 @@
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
+import org.koin.core.parameter.parametersOf
-internal fun NavGraphBuilder.onboardingDestinations(navController: NavHostController) {
- composable {
-
- val viewModel: OnboardingStartViewModel = koinViewModel()
- InitNavigator(navController = navController, viewModel)
+internal fun EntryProviderScope.onboardingEntry(navigator: Navigator) {
+ entry { key ->
+ val viewModel: OnboardingStartViewModel = koinViewModel { parametersOf(key) }
+ InitNavigator(navigator = navigator, viewModel)
OnboardingStartScreen(viewModel = viewModel)
}
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..8bdc1be5
--- /dev/null
+++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkMatcher.kt
@@ -0,0 +1,98 @@
+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
+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..23629e3f
--- /dev/null
+++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkPattern.kt
@@ -0,0 +1,131 @@
+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
+ *
+ * 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}"
+ */
+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..13ccc7e2
--- /dev/null
+++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/DeepLinkRequest.kt
@@ -0,0 +1,26 @@
+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)!!
+ }
+ }
+}
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..22c3657a
--- /dev/null
+++ b/app/src/main/kotlin/nl/q42/template/navigation/deeplink/KeyDecoder.kt
@@ -0,0 +1,74 @@
+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
+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/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/build.dep.navigation.gradle b/build.dep.navigation.gradle
index 5d6ff5ab..2d41d0b2 100644
--- a/build.dep.navigation.gradle
+++ b/build.dep.navigation.gradle
@@ -1,3 +1,4 @@
dependencies {
- implementation libs.composeNavigation
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.navigation3.runtime)
}
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
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..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
@@ -1,15 +1,15 @@
package nl.q42.template.navigation
+import androidx.navigation3.runtime.NavKey
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.
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..df027617
--- /dev/null
+++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/Navigator3.kt
@@ -0,0 +1,60 @@
+package nl.q42.template.navigation.viewmodel
+
+import androidx.navigation3.runtime.NavKey
+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: 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) {
+ 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 {
+ Logger.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 {
+ Logger.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/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..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
@@ -1,20 +1,18 @@
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
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,
- savedStateHandle: SavedStateHandle,
+ @Provided params: Destination.HomeSecond,
) : ViewModel(), RouteNavigator by navigator {
- private val params = savedStateHandle.toRoute()
private val _uiState = MutableStateFlow(HomeSecondViewState(params.title))
val uiState: StateFlow = _uiState.asStateFlow()
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)
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9403af53..a27ee03a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,23 +11,26 @@ 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"
+
+nav3Core = "1.0.0"
+
# Test dependencies
kotlinxCoroutinesTest = "1.10.2"
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"
licensee = "1.14.1"
[libraries]
junit = { module = "junit:junit", version.ref = "junit" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
+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" }
@@ -49,7 +52,11 @@ 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" }
+
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" }