diff --git a/template-compose/app/build.gradle.kts b/template-compose/app/build.gradle.kts index 2954f50c4..5ae942dde 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.core) 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..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,9 +1,28 @@ package co.nimblehq.template.compose.di.modules.main +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.navigation.entries.Home +import co.nimblehq.template.compose.navigation.entries.homeScreenEntry 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 = { + homeScreenEntry(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/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/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/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..87cc182ea --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt @@ -0,0 +1,21 @@ +@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 + +/** + * **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) { + entry { + HomeScreen(viewModel = hiltViewModel(), navigator = navigator) + } +} 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..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 @@ -1,22 +1,78 @@ 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 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 +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 dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.toImmutableSet +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var navigator: Navigator + + @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?) { super.onCreate(savedInstanceState) + setEdgeToEdgeConfig() + handleNewIntent(intent) setContent { ComposeTheme { - AppNavGraph(navController = rememberNavController()) + AppNavigation( + navigator = navigator, + entryProviderScopes = entryProviderScopes.toImmutableSet() + ) } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNewIntent(intent) + } + + private fun handleNewIntent(intent: Intent) { + val uri = intent.data ?: return + val deepLinkNavKey = resolveDeepLinkNavKey(uri) + intent.data = null + if (deepLinkNavKey != null) navigator.goTo(deepLinkNavKey) + } + + 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/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..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 @@ -13,27 +13,28 @@ 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 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 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 @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() @@ -46,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/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..bc87e6b50 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt @@ -0,0 +1,99 @@ +@file:Suppress("ComplexMethod", "ReturnCount", "NestedBlockDepth") + +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, +) { + /** + * 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.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) + 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) { + val message = "Failed to parse path arg [${candidateSegment.stringValue}]" + + " in pattern [${deepLinkPattern.uriPattern}]." + Timber.e(e, message) + 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 arg [$name] in pattern [${deepLinkPattern.uriPattern}].") + 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..02f69f4bc --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt @@ -0,0 +1,136 @@ +@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 + * + * **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 + * 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 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 -> ::parseBooleanArg + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> ::parseCharArg + 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..fe6f9e91c --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt @@ -0,0 +1,32 @@ +package co.nimblehq.template.compose.util + +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( + 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 -> + val value = uri.getQueryParameter(argName) + if (value != null) this[argName] = value + } + } + + // 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..541e6bdaf --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt @@ -0,0 +1,74 @@ +@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. + * + * **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, +) : 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[currentName] != null) { + 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..0050448da --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt @@ -0,0 +1,50 @@ +/* + * 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 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 + * @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) { + @Suppress("UNCHECKED_CAST") + resultEventBus.ensureChannelAndGetFlow(resultKey).collect { result -> + onResult.invoke(result as? T ?: return@collect) + } + } +} 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..13ed024f0 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt @@ -0,0 +1,108 @@ +@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.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 + +/** + * 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. + * + * **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. + */ + fun ensureChannelAndGetFlow(resultKey: String): Flow = + channelMap.computeIfAbsent(resultKey) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST) + }.receiveAsFlow() + + /** + * 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) { + 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()) { + 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/navigation/FakeNavigator.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/navigation/FakeNavigator.kt new file mode 100644 index 000000000..e4891aa63 --- /dev/null +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/navigation/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.template.compose.navigation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.template.compose.navigation.entries.Home +import kotlin.reflect.KClass + +class FakeNavigator(startDestination: Any = Home) : 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) + } + } + + 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..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 @@ -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.navigation.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..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/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..bc2b32593 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,19 +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-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" } @@ -99,16 +101,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 +149,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