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" }