From 240783122fcb50fe51d700de9f0c88327c210087 Mon Sep 17 00:00:00 2001 From: Tiger Date: Thu, 26 Feb 2026 17:55:32 +0700 Subject: [PATCH 1/4] [#617] Migrate template compose to navigation 3 --- template-compose/app/build.gradle.kts | 2 + .../di/modules/main/MainActivityModule.kt | 31 ++++- .../extensions/ComponentActivityExt.kt | 14 ++ .../compose/extensions/SavedStateHandleExt.kt | 11 -- .../template/compose/navigation/Navigator.kt | 18 +++ .../compose/navigation/NavigatorImpl.kt | 29 +++++ .../template/compose/ui/AppDestination.kt | 10 -- .../template/compose/ui/AppNavGraph.kt | 52 -------- .../compose/ui/base/BaseDestination.kt | 19 --- .../template/compose/ui/base/BaseViewModel.kt | 2 +- .../compose/ui/screens/MainActivity.kt | 103 ++++++++++++++- .../ui/screens/main/MainDestination.kt | 8 -- .../compose/ui/screens/main/MainNavGraph.kt | 24 ---- .../ui/screens/main/home/HomeScreen.kt | 13 +- .../template/compose/util/DeepLinkMatcher.kt | 84 ++++++++++++ .../template/compose/util/DeepLinkPattern.kt | 121 ++++++++++++++++++ .../template/compose/util/DeepLinkRequest.kt | 28 ++++ .../template/compose/util/KeyDecoder.kt | 71 ++++++++++ .../template/compose/util/ResultEffect.kt | 45 +++++++ .../template/compose/util/ResultEventBus.kt | 90 +++++++++++++ .../compose/ui/screens/FakeNavigator.kt | 36 ++++++ .../ui/screens/main/home/HomeScreenTest.kt | 7 +- .../data/extensions/ResponseMapping.kt | 2 +- .../data/extensions/ResponseMappingTest.kt | 4 +- .../compose/domain/exceptions/Exceptions.kt | 2 +- template-compose/gradle/libs.versions.toml | 32 +++-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 27 files changed, 704 insertions(+), 156 deletions(-) create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/ComponentActivityExt.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/Navigator.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt create mode 100644 template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt diff --git a/template-compose/app/build.gradle.kts b/template-compose/app/build.gradle.kts index 2954f50c4..cb51075ea 100644 --- a/template-compose/app/build.gradle.kts +++ b/template-compose/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.kover) @@ -142,6 +143,7 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) implementation(libs.timber) debugImplementation(libs.chucker) diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt index 84d43727a..2581c6451 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt @@ -1,9 +1,34 @@ package co.nimblehq.template.compose.di.modules.main +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import co.nimblehq.template.compose.navigation.EntryProviderInstaller +import co.nimblehq.template.compose.navigation.Navigator +import co.nimblehq.template.compose.navigation.NavigatorImpl +import co.nimblehq.template.compose.ui.screens.main.home.Home +import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.multibindings.IntoSet @Module -@InstallIn(ActivityComponent::class) -class MainActivityModule +@InstallIn(ActivityRetainedComponent::class) +object MainActivityModule { + + @Provides + @ActivityRetainedScoped + fun provideNavigator(): Navigator = NavigatorImpl(startDestination = Home) + + @IntoSet + @Provides + fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = { + entry { + HomeScreen( + viewModel = hiltViewModel(), + navigator = navigator + ) + } + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/ComponentActivityExt.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/ComponentActivityExt.kt new file mode 100644 index 000000000..dff876d83 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/ComponentActivityExt.kt @@ -0,0 +1,14 @@ +package co.nimblehq.template.compose.extensions + +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge + +fun ComponentActivity.setEdgeToEdgeConfig() { + enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Force the 3-button navigation bar to be transparent + // See: https://developer.android.com/develop/ui/views/layout/edge-to-edge#create-transparent + window.isNavigationBarContrastEnforced = false + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt deleted file mode 100644 index c041c5545..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nimblehq.template.compose.extensions - -import androidx.lifecycle.SavedStateHandle - -fun SavedStateHandle.getThenRemove(key: String): T? { - return if (contains(key)) { - val value = get(key) - remove(key) - value - } else null -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/Navigator.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/Navigator.kt new file mode 100644 index 000000000..6b2f5b693 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/Navigator.kt @@ -0,0 +1,18 @@ +package co.nimblehq.template.compose.navigation + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.EntryProviderScope +import kotlin.reflect.KClass + +typealias EntryProviderInstaller = EntryProviderScope.() -> Unit + +interface Navigator { + + val backStack: SnapshotStateList + + fun goTo(destination: Any) + + fun goBack() + + fun goBackToLast(destinationClass: KClass<*>) +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt new file mode 100644 index 000000000..e202f3c97 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt @@ -0,0 +1,29 @@ +package co.nimblehq.template.compose.navigation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlin.reflect.KClass + +@ActivityRetainedScoped +class NavigatorImpl(startDestination: Any) : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf(startDestination) + + override fun goTo(destination: Any) { + backStack.add(destination) + } + + override fun goBack() { + backStack.removeLastOrNull() + } + + override fun goBackToLast(destinationClass: KClass<*>) { + val index = backStack.indexOfLast { + destinationClass.isInstance(it) + } + + if (index in backStack.indices) { + backStack.removeRange(index + 1, backStack.size) + } + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt deleted file mode 100644 index be93bcfd7..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt +++ /dev/null @@ -1,10 +0,0 @@ -package co.nimblehq.template.compose.ui - -import co.nimblehq.template.compose.ui.base.BaseDestination - -sealed class AppDestination { - - object RootNavGraph : BaseDestination("rootNavGraph") - - object MainNavGraph : BaseDestination("mainNavGraph") -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt deleted file mode 100644 index e2c662a68..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt +++ /dev/null @@ -1,52 +0,0 @@ -package co.nimblehq.template.compose.ui - -import androidx.compose.runtime.Composable -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink -import co.nimblehq.template.compose.ui.base.BaseDestination -import co.nimblehq.template.compose.ui.screens.main.mainNavGraph - -@Composable -fun AppNavGraph( - navController: NavHostController, -) { - NavHost( - navController = navController, - route = AppDestination.RootNavGraph.route, - startDestination = AppDestination.MainNavGraph.destination - ) { - mainNavGraph(navController = navController) - } -} - -fun NavGraphBuilder.composable( - destination: BaseDestination, - content: @Composable (NavBackStackEntry) -> Unit, -) { - composable( - route = destination.route, - arguments = destination.arguments, - deepLinks = destination.deepLinks.map { - navDeepLink { - uriPattern = it - } - }, - content = content - ) -} - -fun NavHostController.navigate(destination: BaseDestination) { - when (destination) { - is BaseDestination.Up -> { - destination.results.forEach { (key, value) -> - previousBackStackEntry?.savedStateHandle?.set(key, value) - } - navigateUp() - } - else -> navigate(route = destination.destination) - } -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt deleted file mode 100644 index 9efb868b2..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.nimblehq.template.compose.ui.base - -import androidx.navigation.NamedNavArgument - -abstract class BaseDestination(val route: String = "") { - - open val arguments: List = emptyList() - - open val deepLinks: List = emptyList() - - open var destination: String = route - - data class Up(val results: HashMap = hashMapOf()) : BaseDestination() { - - fun addResult(key: String, value: Any) = apply { - results[key] = value - } - } -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt index 44b27a425..ab7d31144 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt @@ -18,7 +18,7 @@ abstract class BaseViewModel : ViewModel() { protected val _error = MutableSharedFlow() val error = _error.asSharedFlow() - protected val _navigator = MutableSharedFlow() + protected val _navigator = MutableSharedFlow() val navigator = _navigator.asSharedFlow() /** diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt index 0b9a609ef..f1ba1f65a 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt @@ -1,22 +1,119 @@ package co.nimblehq.template.compose.ui.screens +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.navigation.compose.rememberNavController -import co.nimblehq.template.compose.ui.AppNavGraph +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import co.nimblehq.template.compose.extensions.setEdgeToEdgeConfig +import co.nimblehq.template.compose.navigation.EntryProviderInstaller +import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.ui.theme.ComposeTheme +import co.nimblehq.template.compose.util.DeepLinkMatcher +import co.nimblehq.template.compose.util.DeepLinkPattern +import co.nimblehq.template.compose.util.DeepLinkRequest +import co.nimblehq.template.compose.util.KeyDecoder +import co.nimblehq.template.compose.util.LocalResultEventBus +import co.nimblehq.template.compose.util.ResultEventBus import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +private const val TWEEN_DURATION_IN_MILLIS = 500 @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + + internal val deepLinkPatterns: List> = listOf() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setEdgeToEdgeConfig() + handleNewIntent(intent) setContent { + val eventBus = remember { ResultEventBus() } + ComposeTheme { - AppNavGraph(navController = rememberNavController()) + CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + }, + popTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + }, + predictivePopTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + } + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNewIntent(intent) + } + + private fun handleNewIntent(intent: Intent) { + val uri: Uri? = intent.data + val deepLinkNavKey: Any? = uri?.let { + val request = DeepLinkRequest(uri) + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() } + match?.let { + KeyDecoder(match.args) + .decodeSerializableValue(match.serializer) + } + } + + if (deepLinkNavKey != null) { + navigator.goTo(deepLinkNavKey) + intent.data = null } } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt deleted file mode 100644 index 038759363..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.nimblehq.template.compose.ui.screens.main - -import co.nimblehq.template.compose.ui.base.BaseDestination - -sealed class MainDestination { - - object Home : BaseDestination("home") -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt deleted file mode 100644 index b6230c0dd..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt +++ /dev/null @@ -1,24 +0,0 @@ -package co.nimblehq.template.compose.ui.screens.main - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.navigation -import co.nimblehq.template.compose.ui.AppDestination -import co.nimblehq.template.compose.ui.composable -import co.nimblehq.template.compose.ui.navigate -import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen - -fun NavGraphBuilder.mainNavGraph( - navController: NavHostController, -) { - navigation( - route = AppDestination.MainNavGraph.route, - startDestination = MainDestination.Home.destination - ) { - composable(MainDestination.Home) { - HomeScreen( - navigator = { destination -> navController.navigate(destination) } - ) - } - } -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt index 13a455c7d..6dfe94714 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package co.nimblehq.template.compose.ui.screens.main.home import androidx.compose.foundation.layout.Arrangement @@ -13,11 +15,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import co.nimblehq.template.compose.R import co.nimblehq.template.compose.extensions.collectAsEffect -import co.nimblehq.template.compose.ui.base.BaseDestination +import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.ui.base.BaseScreen import co.nimblehq.template.compose.ui.models.UiModel import co.nimblehq.template.compose.ui.showToast @@ -26,14 +29,16 @@ import co.nimblehq.template.compose.ui.theme.ComposeTheme import kotlinx.collections.immutable.* import timber.log.Timber +data object Home : NavKey + @Composable fun HomeScreen( + navigator: Navigator, viewModel: HomeViewModel = hiltViewModel(), - navigator: (destination: BaseDestination) -> Unit, ) = BaseScreen { val context = LocalContext.current viewModel.error.collectAsEffect { e -> e.showToast(context) } - viewModel.navigator.collectAsEffect { destination -> navigator(destination) } + viewModel.navigator.collectAsEffect { destination -> navigator.goTo(destination) } val uiModels: ImmutableList by viewModel.uiModels.collectAsStateWithLifecycle() diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt new file mode 100644 index 000000000..e9f30d4e5 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt @@ -0,0 +1,84 @@ +@file:Suppress("ComplexMethod", "ReturnCount", "NestedBlockDepth") + +package co.nimblehq.template.compose.util + +import kotlinx.serialization.KSerializer +import timber.log.Timber + +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.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 { + // 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) { + Timber.e(e, "Failed to parse path value:[$requestedSegment].") + 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) + if (!matchQueryArgs(args)) return null + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } + + private fun matchQueryArgs(args: MutableMap): Boolean { + request.queries.forEach { query -> + val name = query.key + val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to parse query name:[$name] value:[${query.value}].") + return false + } + args[name] = queryParsedValue + } + return true + } +} + + +/** + * 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, +) diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt new file mode 100644 index 000000000..c08d23060 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt @@ -0,0 +1,121 @@ +@file:Suppress("ComplexMethod") +@file:OptIn(ExperimentalSerializationApi::class) + +package co.nimblehq.template.compose.util + +import android.net.Uri +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +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) + 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) + 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 -> { it -> it.first() } + 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/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt new file mode 100644 index 000000000..2d15a2b0a --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt @@ -0,0 +1,28 @@ +package co.nimblehq.template.compose.util + +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/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt new file mode 100644 index 000000000..0f844adeb --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt @@ -0,0 +1,71 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package co.nimblehq.template.compose.util + +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 back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +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/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt new file mode 100644 index 000000000..41b5bf6a4 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package co.nimblehq.template.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +// Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEffect.kt + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + crossinline onResult: suspend (T) -> Unit +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + } + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt new file mode 100644 index 000000000..b26508f0c --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt @@ -0,0 +1,90 @@ +@file:Suppress("CompositionLocalAllowlist") + +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package co.nimblehq.template.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +// Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEventBus.kt + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} + +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +class ResultEventBus { + /** + * Map from the result key to a channel of results. + */ + val channelMap: MutableMap> = mutableMapOf() + + /** + * Provides a flow for the given resultKey. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + /** + * Sends a result into the channel associated with the given resultKey. + */ + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + channelMap[resultKey]?.trySend(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap[resultKey]?.close() + channelMap.remove(resultKey) + } +} diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt new file mode 100644 index 000000000..23fbc401c --- /dev/null +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.template.compose.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.template.compose.navigation.Navigator +import kotlin.reflect.KClass + +class FakeNavigator : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf() + + override fun goTo(destination: Any) { + backStack.add(destination) + } + + override fun goBack() { + backStack.removeLastOrNull() + } + + override fun goBackToLast(destinationClass: KClass<*>) { + val index = backStack.indexOfLast { + destinationClass.isInstance(it) + } + + if (index in backStack.indices) { + backStack.removeRange(index + 1, backStack.size) + } + } + + fun addToBackStack(listOfPastDestinations: List) { + backStack.addAll(listOfPastDestinations) + } + + fun currentScreen(): Any? = backStack.lastOrNull() + + fun currentScreenClass(): KClass<*>? = backStack.lastOrNull()?.let { it::class } +} diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt index e3a2d12d2..7d60da8d6 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt @@ -7,8 +7,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import co.nimblehq.template.compose.R import co.nimblehq.template.compose.domain.usecases.UseCase import co.nimblehq.template.compose.test.MockUtil -import co.nimblehq.template.compose.ui.base.BaseDestination import co.nimblehq.template.compose.ui.screens.BaseScreenTest +import co.nimblehq.template.compose.ui.screens.FakeNavigator import co.nimblehq.template.compose.ui.screens.MainActivity import co.nimblehq.template.compose.ui.theme.ComposeTheme import io.kotest.matchers.shouldBe @@ -31,7 +31,7 @@ class HomeScreenTest : BaseScreenTest() { private val mockUseCase: UseCase = mockk() private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null + private lateinit var fakeNavigator: FakeNavigator @Before fun setUp() { @@ -62,12 +62,13 @@ class HomeScreenTest : BaseScreenTest() { testBody: AndroidComposeTestRule, MainActivity>.() -> Unit, ) { initViewModel() + fakeNavigator = FakeNavigator() composeRule.activity.setContent { ComposeTheme { HomeScreen( viewModel = viewModel, - navigator = { destination -> expectedDestination = destination } + navigator = fakeNavigator ) } } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/extensions/ResponseMapping.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/extensions/ResponseMapping.kt index e2c821b47..c022b29a9 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/extensions/ResponseMapping.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/extensions/ResponseMapping.kt @@ -25,7 +25,7 @@ fun flowTransform(@BuilderInference block: suspend FlowCollector.() -> T) private fun Throwable.mapError(): Throwable { return when (this) { is UnknownHostException, - is InterruptedIOException -> NoConnectivityException + is InterruptedIOException -> NoConnectivityException() is HttpException -> { val errorResponse = parseErrorResponse(response()) ApiException( diff --git a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt index 03e388748..36a46d0d4 100644 --- a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt +++ b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt @@ -24,7 +24,7 @@ class ResponseMappingTest { flowTransform { throw UnknownHostException() }.catch { - it shouldBe NoConnectivityException + it shouldBe NoConnectivityException() }.collect() } @@ -34,7 +34,7 @@ class ResponseMappingTest { flowTransform { throw InterruptedIOException() }.catch { - it shouldBe NoConnectivityException + it shouldBe NoConnectivityException() }.collect() } diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/exceptions/Exceptions.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/exceptions/Exceptions.kt index bbb01e775..46110a55f 100644 --- a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/exceptions/Exceptions.kt +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/exceptions/Exceptions.kt @@ -2,7 +2,7 @@ package co.nimblehq.template.compose.domain.exceptions import co.nimblehq.template.compose.domain.models.Error -object NoConnectivityException : RuntimeException() +class NoConnectivityException : RuntimeException() data class ApiException( val error: Error?, diff --git a/template-compose/gradle/libs.versions.toml b/template-compose/gradle/libs.versions.toml index 5bba61ce7..9f90be58c 100644 --- a/template-compose/gradle/libs.versions.toml +++ b/template-compose/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -androidCompileSdk = "35" +androidCompileSdk = "36" androidMinSdk = "24" androidTargetSdk = "34" androidVersionCode = "1" @@ -7,16 +7,16 @@ androidVersionName = "1.0.0" accompanist = "0.30.1" chucker = "4.2.0" -composeBom = "2025.02.00" -# @kaungkhantsoe Will update in a separate PR -composeNavigation = "2.5.3" +composeBom = "2025.12.01" +hiltLifecycleViewmodel = "1.3.0" +kotlinxSerialization = "1.7.3" +navigation3 = "1.0.0" core = "1.15.0" datastore = "1.1.3" detekt = "1.21.0" detektRules = "0.3.3" -gradle = "8.8.2" -hilt = "2.53" -hiltNavigation = "1.2.0" +gradle = "8.9.1" +hilt = "2.54" javaxInject = "1" junit = "4.13.2" kotest = "5.6.2" @@ -24,8 +24,8 @@ kotlin = "2.1.10" kotlinxCollectionsImmutable = "0.3.6" kotlinxCoroutines = "1.7.3" kover = "0.9.1" -ksp = "2.1.0-1.0.29" -lifecycle = "2.8.7" +ksp = "2.1.10-1.0.29" +lifecycle = "2.10.0" mockk = "1.13.8" moshi = "1.15.1" nimbleCommon = "0.1.2" @@ -43,6 +43,9 @@ androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "cor androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security" } # Compose @@ -51,18 +54,18 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } -compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } +hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltLifecycleViewmodel" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } # Kotlin kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } # Log timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } @@ -99,16 +102,18 @@ androidx = [ "androidx-core", "androidx-lifecycle-runtime", "androidx-lifecycle-compose", + "androidx-lifecycle-viewmodel-navigation3", + "androidx-navigation3-runtime", + "androidx-navigation3-ui", ] compose = [ "compose-ui", "compose-ui-tooling-preview", "compose-material3", - "compose-navigation", ] hilt = [ "hilt-android", - "hilt-navigation", + "hilt-lifecycle-viewmodel-compose", ] retrofit = [ "retrofit", @@ -145,4 +150,5 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/template-compose/gradle/wrapper/gradle-wrapper.properties b/template-compose/gradle/wrapper/gradle-wrapper.properties index b7d706710..6b35d6d13 100644 --- a/template-compose/gradle/wrapper/gradle-wrapper.properties +++ b/template-compose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Aug 22 12:11:55 ICT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 00cdba1c4c0193d827a011666af203eff8b7e935 Mon Sep 17 00:00:00 2001 From: Tiger Date: Wed, 11 Mar 2026 14:28:39 +0700 Subject: [PATCH 2/4] [#617] Resolve codeRabbit comments --- template-compose/app/build.gradle.kts | 2 +- .../compose/ui/screens/MainActivity.kt | 21 ++++++++++-- .../template/compose/util/DeepLinkMatcher.kt | 19 +++++++++-- .../template/compose/util/DeepLinkPattern.kt | 19 +++++++++-- .../template/compose/util/DeepLinkRequest.kt | 6 +++- .../template/compose/util/KeyDecoder.kt | 5 ++- .../template/compose/util/ResultEffect.kt | 11 ++++-- .../template/compose/util/ResultEventBus.kt | 34 ++++++++++++++----- .../compose/ui/screens/FakeNavigator.kt | 5 +-- .../data/extensions/ResponseMappingTest.kt | 5 +-- template-compose/gradle/libs.versions.toml | 3 +- 11 files changed, 103 insertions(+), 27 deletions(-) diff --git a/template-compose/app/build.gradle.kts b/template-compose/app/build.gradle.kts index cb51075ea..5ae942dde 100644 --- a/template-compose/app/build.gradle.kts +++ b/template-compose/app/build.gradle.kts @@ -143,7 +143,7 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.core) implementation(libs.timber) debugImplementation(libs.chucker) diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt index f1ba1f65a..b3885f54e 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt @@ -39,6 +39,17 @@ class MainActivity : ComponentActivity() { @Inject lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + /** + * List of deep link patterns supported by the app. + * + * **Template note:** Navigation 3 does not natively support deep links. Add [DeepLinkPattern] + * instances here for each deep link your app should handle. + * + * Example: + * ``` + * DeepLinkPattern(Home.serializer(), Uri.parse("https://example.com/home")) + * ``` + */ internal val deepLinkPatterns: List> = listOf() override fun onCreate(savedInstanceState: Bundle?) { @@ -105,9 +116,13 @@ class MainActivity : ComponentActivity() { val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> DeepLinkMatcher(request, pattern).match() } - match?.let { - KeyDecoder(match.args) - .decodeSerializableValue(match.serializer) + try { + match?.let { + KeyDecoder(match.args).decodeSerializableValue(match.serializer) + } + } catch (_: Exception) { + intent.data = null + null } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt index e9f30d4e5..bc87e6b50 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt @@ -5,6 +5,17 @@ package co.nimblehq.template.compose.util import kotlinx.serialization.KSerializer import timber.log.Timber +/** + * Matches a [DeepLinkRequest] against a [DeepLinkPattern]. + * + * **Template note:** Navigation 3 does not natively support deep links. This is a custom + * implementation provided as template boilerplate. Register [DeepLinkPattern] instances in + * [MainActivity.deepLinkPatterns] to enable deep link handling. + * + * @param T the backstack key type associated with the matched deeplink + * @param request the incoming deeplink request to match + * @param deepLinkPattern the supported deeplink pattern to match against + */ internal class DeepLinkMatcher( val request: DeepLinkRequest, val deepLinkPattern: DeepLinkPattern, @@ -15,6 +26,8 @@ internal class DeepLinkMatcher( * 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.host != deepLinkPattern.uriPattern.host) return null if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null // exact match (url does not contain any arguments) if (request.uri == deepLinkPattern.uriPattern) @@ -37,7 +50,9 @@ internal class DeepLinkMatcher( val parsedValue = try { candidateSegment.typeParser.invoke(requestedSegment) } catch (e: IllegalArgumentException) { - Timber.e(e, "Failed to parse path value:[$requestedSegment].") + val message = "Failed to parse path arg [${candidateSegment.stringValue}]" + + " in pattern [${deepLinkPattern.uriPattern}]." + Timber.e(e, message) return null } args[candidateSegment.stringValue] = parsedValue @@ -59,7 +74,7 @@ internal class DeepLinkMatcher( val queryParsedValue = try { queryStringParser.invoke(query.value) } catch (e: IllegalArgumentException) { - Timber.e(e, "Failed to parse query name:[$name] value:[${query.value}].") + Timber.e(e, "Failed to parse query arg [$name] in pattern [${deepLinkPattern.uriPattern}].") return false } args[name] = queryParsedValue diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt index c08d23060..02f69f4bc 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt @@ -13,6 +13,10 @@ import java.io.Serializable /** * Parse a supported deeplink and stores its metadata as a easily readable format * + * **Template note:** Navigation 3 does not natively support deep links. This is a custom + * implementation provided as template boilerplate. Register instances of this class in + * [MainActivity.deepLinkPatterns] to enable deep link handling. + * * 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 @@ -103,13 +107,24 @@ internal class DeepLinkPattern( private typealias TypeParser = (String) -> Serializable +private fun parseBooleanArg(s: String): Boolean = when (s.lowercase()) { + "true" -> true + "false" -> false + else -> throw IllegalArgumentException("Cannot parse '$s' as Boolean; expected 'true' or 'false'") +} + +private fun parseCharArg(s: String): Char { + if (s.length == 1) return s[0] + throw IllegalArgumentException("Cannot parse '$s' as Char; expected exactly 1 character") +} + private fun getTypeParser(kind: SerialKind): TypeParser { return when (kind) { PrimitiveKind.STRING -> Any::toString PrimitiveKind.INT -> String::toInt - PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BOOLEAN -> ::parseBooleanArg PrimitiveKind.BYTE -> String::toByte - PrimitiveKind.CHAR -> { it -> it.first() } + PrimitiveKind.CHAR -> ::parseCharArg PrimitiveKind.DOUBLE -> String::toDouble PrimitiveKind.FLOAT -> String::toFloat PrimitiveKind.LONG -> String::toLong diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt index 2d15a2b0a..fe6f9e91c 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt @@ -5,6 +5,9 @@ import android.net.Uri /** * Parse the requested Uri and store it in a easily readable format * + * **Template note:** Navigation 3 does not natively support deep links. This is part of a + * custom deep link implementation provided as template boilerplate. + * * @param uri the target deeplink uri to link to */ internal class DeepLinkRequest( @@ -20,7 +23,8 @@ internal class DeepLinkRequest( */ val queries = buildMap { uri.queryParameterNames.forEach { argName -> - this[argName] = uri.getQueryParameter(argName) ?: "" + val value = uri.getQueryParameter(argName) + if (value != null) this[argName] = value } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt index 0f844adeb..541e6bdaf 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt @@ -13,6 +13,9 @@ import kotlinx.serialization.modules.SerializersModule * Decodes the list of arguments into a back stack key * * **IMPORTANT** This decoder assumes that all argument types are Primitives. + * + * **Template note:** Navigation 3 does not natively support deep links. This is part of a + * custom deep link implementation provided as template boilerplate. */ internal class KeyDecoder( private val arguments: Map, @@ -46,7 +49,7 @@ internal class KeyDecoder( 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)) { + if (arguments[currentName] != null) { elementIndex = currentIndex elementName = currentName return elementIndex diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt index 41b5bf6a4..e3a90144a 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt @@ -22,10 +22,14 @@ import androidx.compose.runtime.LaunchedEffect // Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEffect.kt /** - * An Effect to provide a result even between different screens + * An Effect to provide a result event between different screens. * * The trailing lambda provides the result from a flow of results. * + * **Template note:** Navigation 3 does not have a built-in result passing mechanism between + * screens. Use this in a destination Composable to receive a result sent via + * [ResultEventBus.sendResult]. + * * @param resultEventBus the ResultEventBus to retrieve the result from. The default value * is read from the `LocalResultEventBus` composition local. * @param resultKey the key that should be associated with this effect @@ -37,8 +41,9 @@ inline fun ResultEffect( resultKey: String = T::class.toString(), crossinline onResult: suspend (T) -> Unit ) { - LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { - resultEventBus.getResultFlow(resultKey)?.collect { result -> + LaunchedEffect(resultKey) { + @Suppress("UNCHECKED_CAST") + resultEventBus.ensureChannelAndGetFlow(resultKey).collect { result -> onResult.invoke(result as T) } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt index b26508f0c..13ed024f0 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt @@ -25,7 +25,9 @@ import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow +import java.util.concurrent.ConcurrentHashMap // Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEventBus.kt @@ -47,7 +49,7 @@ object LocalResultEventBus { * Provides a [ResultEventBus] to the composition */ infix fun provides( - bus: ResultEventBus + bus: ResultEventBus, ): ProvidedValue { return LocalResultEventBus.provides(bus) } @@ -57,12 +59,29 @@ object LocalResultEventBus { * An EventBus for passing results between multiple sets of screens. * * It provides a solution for event based results. + * + * **Template note:** Navigation 3 does not have a built-in result passing mechanism + * between screens. Use [sendResult] to send a result from a source screen and [ResultEffect] + * in the destination screen to receive it. [ResultEventBus] is provided via + * [LocalResultEventBus] in [MainActivity]. */ class ResultEventBus { /** * Map from the result key to a channel of results. + * + * Cannot be private because it is accessed from public inline functions + * ([sendResult], [getResultFlow], [removeResult]). + */ + @Suppress("MemberVisibilityCanBePrivate") + val channelMap = ConcurrentHashMap>() + + /** + * Ensures a channel exists for [resultKey] and returns its flow. */ - val channelMap: MutableMap> = mutableMapOf() + fun ensureChannelAndGetFlow(resultKey: String): Flow = + channelMap.computeIfAbsent(resultKey) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST) + }.receiveAsFlow() /** * Provides a flow for the given resultKey. @@ -74,17 +93,16 @@ class ResultEventBus { * Sends a result into the channel associated with the given resultKey. */ inline fun sendResult(resultKey: String = T::class.toString(), result: T) { - if (!channelMap.contains(resultKey)) { - channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) - } - channelMap[resultKey]?.trySend(result) + channelMap.computeIfAbsent(resultKey) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST) + }.trySend(result) } /** * Removes all results associated with the given key from the store. */ inline fun removeResult(resultKey: String = T::class.toString()) { - channelMap[resultKey]?.close() - channelMap.remove(resultKey) + val channel = channelMap[resultKey] ?: return + while (channel.tryReceive().isSuccess) { /* drain buffered items */ } } } diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt index 23fbc401c..d2bce2d70 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt @@ -3,10 +3,11 @@ package co.nimblehq.template.compose.ui.screens import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import co.nimblehq.template.compose.navigation.Navigator +import co.nimblehq.template.compose.ui.screens.main.home.Home import kotlin.reflect.KClass -class FakeNavigator : Navigator { - override val backStack: SnapshotStateList = mutableStateListOf() +class FakeNavigator(startDestination: Any = Home) : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf(startDestination) override fun goTo(destination: Any) { backStack.add(destination) diff --git a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt index 36a46d0d4..b12e97faf 100644 --- a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt +++ b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt @@ -6,6 +6,7 @@ import co.nimblehq.template.compose.domain.exceptions.ApiException import co.nimblehq.template.compose.domain.exceptions.NoConnectivityException import co.nimblehq.template.compose.domain.models.Model import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect @@ -24,7 +25,7 @@ class ResponseMappingTest { flowTransform { throw UnknownHostException() }.catch { - it shouldBe NoConnectivityException() + it.shouldBeInstanceOf() }.collect() } @@ -34,7 +35,7 @@ class ResponseMappingTest { flowTransform { throw InterruptedIOException() }.catch { - it shouldBe NoConnectivityException() + it.shouldBeInstanceOf() }.collect() } diff --git a/template-compose/gradle/libs.versions.toml b/template-compose/gradle/libs.versions.toml index 9f90be58c..bc2b32593 100644 --- a/template-compose/gradle/libs.versions.toml +++ b/template-compose/gradle/libs.versions.toml @@ -65,8 +65,7 @@ javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "j # Kotlin kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } - +kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } # Log timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } From 0e6b26d0924e02ac99f6b34ce17da8eb2e4ae3c6 Mon Sep 17 00:00:00 2001 From: Tiger Date: Thu, 12 Mar 2026 14:12:13 +0700 Subject: [PATCH 3/4] [#617] Extract navigation component out of main activity and implement separate navEntry class --- .../di/modules/main/MainActivityModule.kt | 12 +-- .../compose/navigation/AppNavigation.kt | 53 +++++++++++ .../navigation/entries/HomeScreenNavEntry.kt | 17 ++++ .../compose/ui/screens/MainActivity.kt | 94 ++++--------------- .../ui/screens/main/home/HomeScreen.kt | 10 +- .../compose/ui/screens/FakeNavigator.kt | 2 +- 6 files changed, 96 insertions(+), 92 deletions(-) create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt index 2581c6451..4681e7a69 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt @@ -1,11 +1,10 @@ package co.nimblehq.template.compose.di.modules.main -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import co.nimblehq.template.compose.navigation.EntryProviderInstaller import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.navigation.NavigatorImpl -import co.nimblehq.template.compose.ui.screens.main.home.Home -import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen +import co.nimblehq.template.compose.navigation.entries.Home +import co.nimblehq.template.compose.navigation.entries.homeScreenEntry import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,11 +23,6 @@ object MainActivityModule { @IntoSet @Provides fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = { - entry { - HomeScreen( - viewModel = hiltViewModel(), - navigator = navigator - ) - } + homeScreenEntry(navigator) } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt new file mode 100644 index 000000000..98d663f89 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt @@ -0,0 +1,53 @@ +package co.nimblehq.template.compose.navigation + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import co.nimblehq.template.compose.util.LocalResultEventBus +import co.nimblehq.template.compose.util.ResultEventBus +import kotlinx.collections.immutable.ImmutableSet + +private const val TWEEN_DURATION_IN_MILLIS = 500 + +@Composable +fun AppNavigation( + navigator: Navigator, + entryProviderScopes: ImmutableSet, +) { + val eventBus = remember { ResultEventBus() } + CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + }, + transitionSpec = { horizontalSlideTransition(isPop = false) }, + popTransitionSpec = { horizontalSlideTransition(isPop = true) }, + predictivePopTransitionSpec = { horizontalSlideTransition(isPop = true) } + ) + } +} + +private fun horizontalSlideTransition(isPop: Boolean): ContentTransform = + slideInHorizontally( + initialOffsetX = { if (isPop) -it else it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { if (isPop) it else -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt new file mode 100644 index 000000000..28f0e4983 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt @@ -0,0 +1,17 @@ +@file:Suppress("MatchingDeclarationName") + +package co.nimblehq.template.compose.navigation.entries + +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import co.nimblehq.template.compose.navigation.Navigator +import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen + +data object Home : NavKey + +fun EntryProviderScope.homeScreenEntry(navigator: Navigator) { + entry { + HomeScreen(viewModel = hiltViewModel(), navigator = navigator) + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt index b3885f54e..c4c2edc5f 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt @@ -5,17 +5,8 @@ import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import co.nimblehq.template.compose.extensions.setEdgeToEdgeConfig +import co.nimblehq.template.compose.navigation.AppNavigation import co.nimblehq.template.compose.navigation.EntryProviderInstaller import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.ui.theme.ComposeTheme @@ -23,13 +14,10 @@ import co.nimblehq.template.compose.util.DeepLinkMatcher import co.nimblehq.template.compose.util.DeepLinkPattern import co.nimblehq.template.compose.util.DeepLinkRequest import co.nimblehq.template.compose.util.KeyDecoder -import co.nimblehq.template.compose.util.LocalResultEventBus -import co.nimblehq.template.compose.util.ResultEventBus import dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.toImmutableSet import javax.inject.Inject -private const val TWEEN_DURATION_IN_MILLIS = 500 - @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -57,49 +45,11 @@ class MainActivity : ComponentActivity() { setEdgeToEdgeConfig() handleNewIntent(intent) setContent { - val eventBus = remember { ResultEventBus() } - ComposeTheme { - CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) { - NavDisplay( - backStack = navigator.backStack, - onBack = { navigator.goBack() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ), - entryProvider = entryProvider { - entryProviderScopes.forEach { builder -> this.builder() } - }, - transitionSpec = { - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) togetherWith slideOutHorizontally( - targetOffsetX = { -it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) - }, - popTransitionSpec = { - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) togetherWith slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) - }, - predictivePopTransitionSpec = { - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) togetherWith slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(TWEEN_DURATION_IN_MILLIS) - ) - } - ) - } + AppNavigation( + navigator = navigator, + entryProviderScopes = entryProviderScopes.toImmutableSet() + ) } } } @@ -110,25 +60,19 @@ class MainActivity : ComponentActivity() { } private fun handleNewIntent(intent: Intent) { - val uri: Uri? = intent.data - val deepLinkNavKey: Any? = uri?.let { - val request = DeepLinkRequest(uri) - val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> - DeepLinkMatcher(request, pattern).match() - } - try { - match?.let { - KeyDecoder(match.args).decodeSerializableValue(match.serializer) - } - } catch (_: Exception) { - intent.data = null - null - } - } + val uri = intent.data ?: return + val deepLinkNavKey = resolveDeepLinkNavKey(uri) + intent.data = null + if (deepLinkNavKey != null) navigator.goTo(deepLinkNavKey) + } - if (deepLinkNavKey != null) { - navigator.goTo(deepLinkNavKey) - intent.data = null - } + private fun resolveDeepLinkNavKey(uri: Uri): Any? { + val request = DeepLinkRequest(uri) + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } ?: return null + return try { + KeyDecoder(match.args).decodeSerializableValue(match.serializer) + } catch (_: Exception) { null } } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt index 6dfe94714..83738d487 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("MatchingDeclarationName") - package co.nimblehq.template.compose.ui.screens.main.home import androidx.compose.foundation.layout.Arrangement @@ -17,7 +15,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey import co.nimblehq.template.compose.R import co.nimblehq.template.compose.extensions.collectAsEffect import co.nimblehq.template.compose.navigation.Navigator @@ -26,11 +23,10 @@ import co.nimblehq.template.compose.ui.models.UiModel import co.nimblehq.template.compose.ui.showToast import co.nimblehq.template.compose.ui.theme.AppTheme.dimensions import co.nimblehq.template.compose.ui.theme.ComposeTheme -import kotlinx.collections.immutable.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import timber.log.Timber -data object Home : NavKey - @Composable fun HomeScreen( navigator: Navigator, @@ -51,7 +47,7 @@ fun HomeScreen( @Composable private fun HomeScreenContent( title: String, - uiModels: ImmutableList + uiModels: ImmutableList, ) { Column( modifier = Modifier.fillMaxSize(), diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt index d2bce2d70..15e83f3d2 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt @@ -3,7 +3,7 @@ package co.nimblehq.template.compose.ui.screens import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import co.nimblehq.template.compose.navigation.Navigator -import co.nimblehq.template.compose.ui.screens.main.home.Home +import co.nimblehq.template.compose.navigation.entries.Home import kotlin.reflect.KClass class FakeNavigator(startDestination: Any = Home) : Navigator { From 60edc91b7429ccded38689e6c74b8c9a6b0aa7a1 Mon Sep 17 00:00:00 2001 From: Tiger Date: Fri, 13 Mar 2026 09:30:21 +0700 Subject: [PATCH 4/4] [#617] Move fake navigator, resolve more comments --- .../template/compose/navigation/entries/HomeScreenNavEntry.kt | 4 ++++ .../java/co/nimblehq/template/compose/util/ResultEffect.kt | 4 ++-- .../compose/{ui/screens => navigation}/FakeNavigator.kt | 3 +-- .../template/compose/ui/screens/main/home/HomeScreenTest.kt | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) rename template-compose/app/src/test/java/co/nimblehq/template/compose/{ui/screens => navigation}/FakeNavigator.kt (90%) diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt index 28f0e4983..87cc182ea 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt @@ -8,6 +8,10 @@ import androidx.navigation3.runtime.NavKey import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen +/** + * **Template note:** Annotate this NavKey with `@Serializable` if you need to reference + * its serializer in a [DeepLinkPattern] (e.g. `DeepLinkPattern(Home.serializer(), uri)`). + */ data object Home : NavKey fun EntryProviderScope.homeScreenEntry(navigator: Navigator) { diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt index e3a90144a..0050448da 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt @@ -41,10 +41,10 @@ inline fun ResultEffect( resultKey: String = T::class.toString(), crossinline onResult: suspend (T) -> Unit ) { - LaunchedEffect(resultKey) { + LaunchedEffect(resultKey, resultEventBus) { @Suppress("UNCHECKED_CAST") resultEventBus.ensureChannelAndGetFlow(resultKey).collect { result -> - onResult.invoke(result as T) + onResult.invoke(result as? T ?: return@collect) } } } diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/navigation/FakeNavigator.kt similarity index 90% rename from template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt rename to template-compose/app/src/test/java/co/nimblehq/template/compose/navigation/FakeNavigator.kt index 15e83f3d2..e4891aa63 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/navigation/FakeNavigator.kt @@ -1,8 +1,7 @@ -package co.nimblehq.template.compose.ui.screens +package co.nimblehq.template.compose.navigation import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList -import co.nimblehq.template.compose.navigation.Navigator import co.nimblehq.template.compose.navigation.entries.Home import kotlin.reflect.KClass diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt index 7d60da8d6..980b0a73b 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt @@ -8,7 +8,7 @@ import co.nimblehq.template.compose.R import co.nimblehq.template.compose.domain.usecases.UseCase import co.nimblehq.template.compose.test.MockUtil import co.nimblehq.template.compose.ui.screens.BaseScreenTest -import co.nimblehq.template.compose.ui.screens.FakeNavigator +import co.nimblehq.template.compose.navigation.FakeNavigator import co.nimblehq.template.compose.ui.screens.MainActivity import co.nimblehq.template.compose.ui.theme.ComposeTheme import io.kotest.matchers.shouldBe