From 25d87f2505b750a66978061d2e2906e6038623fd Mon Sep 17 00:00:00 2001 From: Tiger Date: Tue, 2 Dec 2025 09:17:09 +0700 Subject: [PATCH 1/6] [#617] Migrate sample-compose to navigation 3 --- sample-compose/app/build.gradle.kts | 2 + .../compose/ui/screens/FakeNavigator.kt | 36 ++++++ .../ui/screens/main/home/HomeScreenTest.kt | 9 +- .../di/modules/main/MainActivityModule.kt | 71 ++++++++++- .../extensions/ComponentActivityExt.kt | 14 +++ .../sample/compose/navigation/Navigator.kt | 18 +++ .../compose/navigation/NavigatorImpl.kt | 29 +++++ .../sample/compose/ui/AppDestination.kt | 10 -- .../nimblehq/sample/compose/ui/AppNavGraph.kt | 63 ---------- .../sample/compose/ui/base/BaseDestination.kt | 26 ---- .../sample/compose/ui/base/BaseScreen.kt | 4 +- .../sample/compose/ui/base/BaseViewModel.kt | 2 +- .../sample/compose/ui/common/Constants.kt | 3 + .../sample/compose/ui/models/UiModel.kt | 2 + .../sample/compose/ui/screens/MainActivity.kt | 106 +++++++++++++++- .../ui/screens/main/MainDestination.kt | 31 ++--- .../compose/ui/screens/main/MainNavGraph.kt | 50 -------- .../ui/screens/main/home/HomeScreen.kt | 13 +- .../ui/screens/main/home/HomeViewModel.kt | 4 +- .../ui/screens/main/second/SecondScreen.kt | 11 +- .../ui/screens/main/third/ThirdScreen.kt | 5 +- .../sample/compose/util/ComposableUtil.kt | 2 +- .../sample/compose/util/DeepLinkMatcher.kt | 81 ++++++++++++ .../sample/compose/util/DeepLinkPattern.kt | 116 ++++++++++++++++++ .../sample/compose/util/DeepLinkRequest.kt | 28 +++++ .../sample/compose/util/KeyDecoder.kt | 70 +++++++++++ .../sample/compose/util/ResultEffect.kt | 45 +++++++ .../sample/compose/util/ResultEventBus.kt | 86 +++++++++++++ .../compose/ui/screens/FakeNavigator.kt | 36 ++++++ .../ui/screens/main/home/HomeScreenTest.kt | 10 +- .../ui/screens/main/home/HomeViewModelTest.kt | 5 +- sample-compose/domain/build.gradle.kts | 4 + sample-compose/gradle/libs.versions.toml | 26 ++-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 34 files changed, 796 insertions(+), 224 deletions(-) create mode 100644 sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt create mode 100644 sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt diff --git a/sample-compose/app/build.gradle.kts b/sample-compose/app/build.gradle.kts index c135b8fbb..ae65b2da6 100644 --- a/sample-compose/app/build.gradle.kts +++ b/sample-compose/app/build.gradle.kts @@ -3,6 +3,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) @@ -137,6 +138,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) debugImplementation(libs.chucker) releaseImplementation(libs.chucker.noOp) diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt new file mode 100644 index 000000000..01a7cd3d1 --- /dev/null +++ b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.sample.compose.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.sample.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/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt index b090086e8..26942a7e4 100644 --- a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt @@ -13,7 +13,7 @@ import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUs import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase import co.nimblehq.sample.compose.test.MockUtil import co.nimblehq.sample.compose.test.TestDispatchersProvider -import co.nimblehq.sample.compose.ui.base.BaseDestination +import co.nimblehq.sample.compose.ui.screens.FakeNavigator import co.nimblehq.sample.compose.ui.screens.MainActivity import co.nimblehq.sample.compose.ui.screens.main.MainDestination import co.nimblehq.sample.compose.ui.theme.ComposeTheme @@ -43,7 +43,7 @@ class HomeScreenTest { private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null + private lateinit var fakeNavigator: FakeNavigator @Before fun setUp() { @@ -74,17 +74,18 @@ class HomeScreenTest { fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable { onNodeWithText("1").performClick() - assertEquals(expectedDestination, MainDestination.Second) + assertEquals((fakeNavigator.currentScreen() as? MainDestination.Second)?.id, "1") } private fun initComposable( testBody: AndroidComposeTestRule, MainActivity>.() -> Unit ) { + fakeNavigator = FakeNavigator() composeRule.activity.setContent { ComposeTheme { HomeScreen( viewModel = viewModel, - navigator = { destination -> expectedDestination = destination } + navigator = fakeNavigator ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt index e68084012..ae7502bf6 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt @@ -1,9 +1,74 @@ package co.nimblehq.sample.compose.di.modules.main +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.extensions.showToast +import co.nimblehq.sample.compose.navigation.EntryProviderInstaller +import co.nimblehq.sample.compose.navigation.Navigator +import co.nimblehq.sample.compose.navigation.NavigatorImpl +import co.nimblehq.sample.compose.ui.screens.main.MainDestination +import co.nimblehq.sample.compose.ui.screens.main.home.HomeScreen +import co.nimblehq.sample.compose.ui.screens.main.second.SecondScreen +import co.nimblehq.sample.compose.ui.screens.main.third.ThirdScreen +import co.nimblehq.sample.compose.util.LocalResultEventBus +import co.nimblehq.sample.compose.util.ResultEffect 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 = MainDestination.Home) + + @IntoSet + @Provides + fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = + { + entry { + val eventBus = LocalResultEventBus.current + val context = LocalContext.current + + ResultEffect { isUpdated -> + if (isUpdated) { + context.showToast(context.getString(R.string.message_updated)) + } + eventBus.removeResult() + } + + HomeScreen( + viewModel = hiltViewModel(), + navigator = navigator + ) + } + + entry { key -> + val eventBus = LocalResultEventBus.current + + SecondScreen( + id = key.id, + navigator = navigator, + viewModel = hiltViewModel(), + onUpdate = { + eventBus.sendResult(result = true) + navigator.goBack() + } + ) + } + + entry { key -> + ThirdScreen( + model = key.model, + navigator = navigator, + viewModel = hiltViewModel() + ) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt new file mode 100644 index 000000000..60be1f0eb --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt @@ -0,0 +1,14 @@ +package co.nimblehq.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt new file mode 100644 index 000000000..5143029de --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt @@ -0,0 +1,18 @@ +package co.nimblehq.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt new file mode 100644 index 000000000..5765fcc76 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt @@ -0,0 +1,29 @@ +package co.nimblehq.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt deleted file mode 100644 index 325e9c7cd..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt +++ /dev/null @@ -1,10 +0,0 @@ -package co.nimblehq.sample.compose.ui - -import co.nimblehq.sample.compose.ui.base.BaseDestination - -sealed class AppDestination { - - object RootNavGraph : BaseDestination("rootNavGraph") - - object MainNavGraph : BaseDestination("mainNavGraph") -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt deleted file mode 100644 index 4782b215d..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt +++ /dev/null @@ -1,63 +0,0 @@ -package co.nimblehq.sample.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.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.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 - ) -} - -/** - * Navigate to provided [BaseDestination] with a Pair of key value String and Data [parcel] - * Caution to use this method. This method use savedStateHandle to store the Parcelable data. - * When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data. - * eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully. - */ -fun NavHostController.navigate(destination: BaseDestination, parcel: Pair? = null) { - when (destination) { - is BaseDestination.Up -> { - destination.results.forEach { (key, value) -> - previousBackStackEntry?.savedStateHandle?.set(key, value) - } - navigateUp() - } - else -> { - parcel?.let { (key, value) -> - currentBackStackEntry?.savedStateHandle?.set(key, value) - } - navigate(route = destination.destination) - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt deleted file mode 100644 index 03af800b4..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.nimblehq.sample.compose.ui.base - -import androidx.navigation.NamedNavArgument - -const val KeyResultOk = "keyResultOk" - -abstract class BaseDestination(val route: String = "") { - - open val arguments: List = emptyList() - - open val deepLinks: List = listOf( - "https://android.nimblehq.co/$route", - "android://$route", - ) - - open var destination: String = route - - open var parcelableArgument: Pair = "" to null - - data class Up(val results: HashMap = hashMapOf()) : BaseDestination() { - - fun addResult(key: String, value: Any) = apply { - results[key] = value - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt index e2255c0e3..44a01523b 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt @@ -3,7 +3,7 @@ package co.nimblehq.sample.compose.ui.base import androidx.compose.runtime.Composable import androidx.compose.ui.res.colorResource import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.util.setStatusBarColor +import co.nimblehq.sample.compose.util.StatusBarColor @Composable fun BaseScreen( @@ -11,7 +11,7 @@ fun BaseScreen( content: @Composable () -> Unit, ) { if (isDarkStatusBarIcons != null) { - setStatusBarColor( + StatusBarColor( color = colorResource(id = R.color.statusBarColor), darkIcons = isDarkStatusBarIcons, ) diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt index ad7649708..208a97c61 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt new file mode 100644 index 000000000..2ce039bd5 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt @@ -0,0 +1,3 @@ +package co.nimblehq.sample.compose.ui.common + +const val URL_SECOND_SCREEN = "https://sample.nimblehq.co/second?id={id}" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt index b105af0f3..cf8156167 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt @@ -3,7 +3,9 @@ package co.nimblehq.sample.compose.ui.models import android.os.Parcelable import co.nimblehq.sample.compose.domain.models.Model import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @Parcelize data class UiModel( val id: String, diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt index 5c262262b..573b4eb87 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt @@ -1,22 +1,122 @@ package co.nimblehq.sample.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.sample.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.core.net.toUri +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import co.nimblehq.sample.compose.extensions.setEdgeToEdgeConfig +import co.nimblehq.sample.compose.navigation.EntryProviderInstaller +import co.nimblehq.sample.compose.navigation.Navigator +import co.nimblehq.sample.compose.ui.common.URL_SECOND_SCREEN +import co.nimblehq.sample.compose.ui.screens.main.MainDestination import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import co.nimblehq.sample.compose.util.DeepLinkMatcher +import co.nimblehq.sample.compose.util.DeepLinkPattern +import co.nimblehq.sample.compose.util.DeepLinkRequest +import co.nimblehq.sample.compose.util.KeyDecoder +import co.nimblehq.sample.compose.util.LocalResultEventBus +import co.nimblehq.sample.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( + DeepLinkPattern(MainDestination.Second.serializer(), URL_SECOND_SCREEN.toUri()), + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setEdgeToEdgeConfig() setContent { + val eventBus = remember { ResultEventBus() } + + handleNewIntent(intent) + 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) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt index 9383fb055..0b04385e9 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt @@ -1,31 +1,16 @@ package co.nimblehq.sample.compose.ui.screens.main -import androidx.navigation.NavType -import androidx.navigation.navArgument -import co.nimblehq.sample.compose.ui.base.BaseDestination import co.nimblehq.sample.compose.ui.models.UiModel +import kotlinx.serialization.Serializable -const val KeyId = "id" -const val KeyModel = "model" +sealed interface MainDestination { -sealed class MainDestination { + @Serializable + data object Home : MainDestination - object Home : BaseDestination("home") + @Serializable + data class Second(val id: String) : MainDestination - object Second : BaseDestination("second/{$KeyId}") { - - override val arguments = listOf( - navArgument(KeyId) { type = NavType.StringType } - ) - - fun createRoute(id: String) = apply { - destination = "second/$id" - } - } - - object Third : BaseDestination("third") { - fun addParcel(value: UiModel) = apply { - parcelableArgument = KeyModel to value - } - } + @Serializable + data class Third(val model: UiModel) : MainDestination } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt deleted file mode 100644 index 1632ceaad..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt +++ /dev/null @@ -1,50 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.navigation -import co.nimblehq.sample.compose.extensions.getThenRemove -import co.nimblehq.sample.compose.ui.AppDestination -import co.nimblehq.sample.compose.ui.base.KeyResultOk -import co.nimblehq.sample.compose.ui.composable -import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.navigate -import co.nimblehq.sample.compose.ui.screens.main.home.HomeScreen -import co.nimblehq.sample.compose.ui.screens.main.second.SecondScreen -import co.nimblehq.sample.compose.ui.screens.main.third.ThirdScreen - -fun NavGraphBuilder.mainNavGraph( - navController: NavHostController, -) { - navigation( - route = AppDestination.MainNavGraph.route, - startDestination = MainDestination.Home.destination - ) { - composable(destination = MainDestination.Home) { backStackEntry -> - val isResultOk = backStackEntry.savedStateHandle - .getThenRemove(KeyResultOk) ?: false - HomeScreen( - navigator = { destination -> - navController.navigate(destination, destination.parcelableArgument) - }, - isResultOk = isResultOk, - ) - } - - composable(destination = MainDestination.Second) { backStackEntry -> - SecondScreen( - navigator = { destination -> navController.navigate(destination) }, - id = backStackEntry.arguments?.getString(KeyId).orEmpty() - ) - } - - composable(destination = MainDestination.Third) { - ThirdScreen( - navigator = { destination -> navController.navigate(destination) }, - model = navController.previousBackStackEntry?.savedStateHandle?.get( - KeyModel - ) - ) - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt index c6e2bccfc..713e56f70 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt @@ -19,7 +19,7 @@ import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.extensions.collectAsEffect import co.nimblehq.sample.compose.extensions.showToast import co.nimblehq.sample.compose.lib.IsLoading -import co.nimblehq.sample.compose.ui.base.BaseDestination +import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel @@ -34,15 +34,14 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun HomeScreen( - isResultOk: Boolean = false, - navigator: (destination: BaseDestination) -> Unit, + navigator: Navigator, viewModel: HomeViewModel = hiltViewModel(), ) = BaseScreen( isDarkStatusBarIcons = true, ) { 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 isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() val uiModels: ImmutableList by viewModel.uiModels.collectAsStateWithLifecycle() @@ -55,12 +54,6 @@ fun HomeScreen( } } - LaunchedEffect(Unit) { - if (isResultOk) { - context.showToast(context.getString(R.string.message_updated)) - } - } - CameraPermission() HomeScreenContent( diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt index 00ec8dca3..1dd9fa261 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt @@ -63,10 +63,10 @@ class HomeViewModel @Inject constructor( } fun navigateToSecond(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Second.createRoute(uiModel.id)) } + launch { _navigator.emit(MainDestination.Second(uiModel.id)) } } fun navigateToThird(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Third.addParcel(uiModel)) } + launch { _navigator.emit(MainDestination.Third(uiModel)) } } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt index 98c692229..577e209e1 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt @@ -14,26 +14,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.ui.base.BaseDestination +import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen -import co.nimblehq.sample.compose.ui.base.KeyResultOk import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions import co.nimblehq.sample.compose.ui.theme.ComposeTheme +@Suppress("UnusedPrivateMember") @Composable fun SecondScreen( id: String, - navigator: (destination: BaseDestination) -> Unit, + navigator: Navigator, viewModel: SecondViewModel = hiltViewModel(), + onUpdate: () -> Unit = {}, ) = BaseScreen( isDarkStatusBarIcons = false, ) { SecondScreenContent( id = id, - onUpdateClick = { - navigator(BaseDestination.Up().addResult(KeyResultOk, true)) - }, + onUpdateClick = onUpdate, ) } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt index 98ffc5c74..d1352926f 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt @@ -13,16 +13,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.ui.base.BaseDestination +import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel import co.nimblehq.sample.compose.ui.theme.ComposeTheme +@Suppress("UnusedPrivateMember") @Composable fun ThirdScreen( model: UiModel?, - navigator: (destination: BaseDestination) -> Unit, + navigator: Navigator, viewModel: ThirdViewModel = hiltViewModel(), ) = BaseScreen( isDarkStatusBarIcons = true, diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt index f4830423f..1a4c5dc6c 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt @@ -8,7 +8,7 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController @SuppressLint("ComposableNaming") @Composable -fun setStatusBarColor( +fun StatusBarColor( color: Color, darkIcons: Boolean, ) { diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt new file mode 100644 index 000000000..d7b6a7880 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt @@ -0,0 +1,81 @@ +package co.nimblehq.sample.compose.util + +import android.util.Log +import kotlinx.serialization.KSerializer + +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 { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if (requestedSegment != candidateSegment.stringValue) { + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + val queryStringParser = deepLinkPattern.queryValueParsers[name] + queryStringParser?.let { + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt new file mode 100644 index 000000000..e20fd9890 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt @@ -0,0 +1,116 @@ +package co.nimblehq.sample.compose.util + +import android.net.Uri +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 -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt new file mode 100644 index 000000000..49e3a9432 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt @@ -0,0 +1,28 @@ +package co.nimblehq.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt new file mode 100644 index 000000000..54cd8ee06 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt @@ -0,0 +1,70 @@ +package co.nimblehq.sample.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 a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt new file mode 100644 index 000000000..3c47a380d --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/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.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt new file mode 100644 index 000000000..599c95922 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt @@ -0,0 +1,86 @@ +/* + * 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.sample.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.remove(resultKey) + } +} diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt new file mode 100644 index 000000000..01a7cd3d1 --- /dev/null +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.sample.compose.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.sample.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/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt index c8126dad7..953533b3e 100644 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt @@ -9,8 +9,8 @@ import androidx.test.rule.GrantPermissionRule import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.domain.usecases.* import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.ui.base.BaseDestination import co.nimblehq.sample.compose.ui.screens.BaseScreenTest +import co.nimblehq.sample.compose.ui.screens.FakeNavigator import co.nimblehq.sample.compose.ui.screens.MainActivity import co.nimblehq.sample.compose.ui.screens.main.MainDestination import co.nimblehq.sample.compose.ui.theme.ComposeTheme @@ -45,7 +45,7 @@ class HomeScreenTest : BaseScreenTest() { mockk() private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null + private lateinit var fakeNavigator: FakeNavigator @Before fun setUp() { @@ -105,20 +105,20 @@ class HomeScreenTest : BaseScreenTest() { fun `When clicking on a list item, it navigates to Second screen`() = initComposable { onNodeWithText("1").performClick() - assertEquals(expectedDestination, MainDestination.Second) + assertEquals((fakeNavigator.currentScreen() as? MainDestination.Second)?.id, "1") } private fun initComposable( testBody: AndroidComposeTestRule, MainActivity>.() -> Unit, ) { initViewModel() + fakeNavigator = FakeNavigator() composeRule.activity.setContent { ComposeTheme { HomeScreen( - isResultOk = false, viewModel = viewModel, - navigator = { destination -> expectedDestination = destination }, + navigator = fakeNavigator, ) } } diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt index 1d8de6c1d..d6bc6a3d7 100644 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt @@ -83,10 +83,11 @@ class HomeViewModelTest { @Test fun `When calling navigate to Second, it navigates to Second screen`() = runTest { + val uiModel = MockUtil.models[0].toUiModel() viewModel.navigator.test { - viewModel.navigateToSecond(MockUtil.models[0].toUiModel()) + viewModel.navigateToSecond(uiModel) - expectMostRecentItem() shouldBe MainDestination.Second + expectMostRecentItem() shouldBe MainDestination.Second(uiModel.id) } } diff --git a/sample-compose/domain/build.gradle.kts b/sample-compose/domain/build.gradle.kts index 73377e18a..f75166acb 100644 --- a/sample-compose/domain/build.gradle.kts +++ b/sample-compose/domain/build.gradle.kts @@ -9,6 +9,10 @@ java { targetCompatibility = JavaVersion.VERSION_17 } +kotlin { + jvmToolchain(17) +} + dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.javax.inject) diff --git a/sample-compose/gradle/libs.versions.toml b/sample-compose/gradle/libs.versions.toml index f5c7f158b..a70c25236 100644 --- a/sample-compose/gradle/libs.versions.toml +++ b/sample-compose/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -androidCompileSdk = "35" +androidCompileSdk = "36" androidMinSdk = "24" androidTargetSdk = "34" androidVersionCode = "1" @@ -7,15 +7,13 @@ androidVersionName = "3.32.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" core = "1.15.0" datastore = "1.1.3" detekt = "1.21.0" detektRules = "0.3.3" -gradle = "8.8.2" -hilt = "2.53" +gradle = "8.9.1" +hilt = "2.54" hiltNavigation = "1.2.0" javaxInject = "1" junit = "4.13.2" @@ -23,9 +21,11 @@ kotest = "5.6.2" kotlin = "2.1.10" kotlinxCollectionsImmutable = "0.3.6" kotlinxCoroutines = "1.7.3" +kotlinxSerialization = "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" +navigation3 = "1.0.0" mockk = "1.13.8" moshi = "1.15.1" nimbleCommon = "0.1.2" @@ -43,8 +43,11 @@ turbine = "0.13.0" androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "core" } 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-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -53,7 +56,6 @@ compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } 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" } accompanist-systemUiController = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } @@ -67,6 +69,7 @@ javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "j kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "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" } @@ -110,13 +113,15 @@ 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-foundation", "compose-material3", - "compose-navigation", ] hilt = [ "hilt-android", @@ -156,4 +161,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/sample-compose/gradle/wrapper/gradle-wrapper.properties b/sample-compose/gradle/wrapper/gradle-wrapper.properties index 7e82773a2..e1ee78772 100644 --- a/sample-compose/gradle/wrapper/gradle-wrapper.properties +++ b/sample-compose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jun 28 08:46:12 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-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 92a00d5b04b0d49bac050aff30b73af979ac8b43 Mon Sep 17 00:00:00 2001 From: Tiger Date: Wed, 11 Feb 2026 17:04:02 +0700 Subject: [PATCH 2/6] [#617] Cleanup remaining nav2 logic and some unused codes --- sample-compose/app/build.gradle.kts | 1 - .../sample/compose/extensions/SavedStateHandleExt.kt | 11 ----------- .../co/nimblehq/sample/compose/ui/models/UiModel.kt | 5 +---- 3 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt diff --git a/sample-compose/app/build.gradle.kts b/sample-compose/app/build.gradle.kts index ae65b2da6..38f9fa219 100644 --- a/sample-compose/app/build.gradle.kts +++ b/sample-compose/app/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.android.application) 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) diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt deleted file mode 100644 index 61fe3a7d5..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nimblehq.sample.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/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt index cf8156167..9b797fc09 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt @@ -1,16 +1,13 @@ package co.nimblehq.sample.compose.ui.models -import android.os.Parcelable import co.nimblehq.sample.compose.domain.models.Model -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable -@Parcelize data class UiModel( val id: String, val username: String, -) : Parcelable +) fun Model.toUiModel() = UiModel( id.toString(), From 0b3fbc7813c1989416e74abe3c87ba6c7c050913 Mon Sep 17 00:00:00 2001 From: Tiger Date: Mon, 16 Feb 2026 15:08:52 +0700 Subject: [PATCH 3/6] [#617] Resolve comments --- .../sample/compose/ui/screens/main/home/HomeScreenTest.kt | 2 +- .../co/nimblehq/sample/compose/ui/screens/MainActivity.kt | 8 +++++--- .../co/nimblehq/sample/compose/util/DeepLinkMatcher.kt | 2 +- .../co/nimblehq/sample/compose/util/DeepLinkPattern.kt | 2 +- .../co/nimblehq/sample/compose/util/DeepLinkRequest.kt | 2 +- .../java/co/nimblehq/sample/compose/util/KeyDecoder.kt | 2 +- .../co/nimblehq/sample/compose/util/ResultEventBus.kt | 1 + .../sample/compose/ui/screens/main/home/HomeScreenTest.kt | 2 +- 8 files changed, 12 insertions(+), 9 deletions(-) diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt index 26942a7e4..8dc73525f 100644 --- a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt @@ -74,7 +74,7 @@ class HomeScreenTest { fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable { onNodeWithText("1").performClick() - assertEquals((fakeNavigator.currentScreen() as? MainDestination.Second)?.id, "1") + assertEquals("1", (fakeNavigator.currentScreen() as? MainDestination.Second)?.id) } private fun initComposable( diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt index 573b4eb87..a4ac9aafe 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt @@ -49,11 +49,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setEdgeToEdgeConfig() + handleNewIntent(intent) setContent { val eventBus = remember { ResultEventBus() } - handleNewIntent(intent) - ComposeTheme { CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) { NavDisplay( @@ -117,6 +116,9 @@ class MainActivity : ComponentActivity() { } } - if (deepLinkNavKey != null) navigator.goTo(deepLinkNavKey) + if (deepLinkNavKey != null) { + navigator.goTo(deepLinkNavKey) + intent.data = null + } } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt index d7b6a7880..3d61ab678 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt @@ -78,4 +78,4 @@ internal data class DeepLinkMatchResult( val args: Map ) -const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" +const val TAG_LOG_ERROR = "SampleComposeDeepLink" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt index e20fd9890..abc9f0c01 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt @@ -104,7 +104,7 @@ private fun getTypeParser(kind: SerialKind): TypeParser { PrimitiveKind.INT -> String::toInt PrimitiveKind.BOOLEAN -> String::toBoolean PrimitiveKind.BYTE -> String::toByte - PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.CHAR -> { it -> it.first() } PrimitiveKind.DOUBLE -> String::toDouble PrimitiveKind.FLOAT -> String::toFloat PrimitiveKind.LONG -> String::toLong diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt index 49e3a9432..45216527c 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt @@ -20,7 +20,7 @@ internal class DeepLinkRequest( */ val queries = buildMap { uri.queryParameterNames.forEach { argName -> - this[argName] = uri.getQueryParameter(argName)!! + this[argName] = uri.getQueryParameter(argName) ?: "" } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt index 54cd8ee06..436f11b59 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.SerializersModule /** - * Decodes the list of arguments into a a back stack key + * Decodes the list of arguments into a back stack key * * **IMPORTANT** This decoder assumes that all argument types are Primitives. */ diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt index 599c95922..97c891f2f 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt @@ -81,6 +81,7 @@ class ResultEventBus { * 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/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt index 953533b3e..53d16baa4 100644 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt @@ -105,7 +105,7 @@ class HomeScreenTest : BaseScreenTest() { fun `When clicking on a list item, it navigates to Second screen`() = initComposable { onNodeWithText("1").performClick() - assertEquals((fakeNavigator.currentScreen() as? MainDestination.Second)?.id, "1") + assertEquals("1", (fakeNavigator.currentScreen() as? MainDestination.Second)?.id) } private fun initComposable( From f9abb38389e79b8a707a23dc3b2fe30789f53641 Mon Sep 17 00:00:00 2001 From: Tiger Date: Mon, 16 Feb 2026 15:11:32 +0700 Subject: [PATCH 4/6] [#617] Migrate from hilt-navigation-compose to hilt-lifecycle-viewmodel-compose based on comment --- .../sample/compose/di/modules/main/MainActivityModule.kt | 2 +- .../sample/compose/ui/screens/main/home/HomeScreen.kt | 2 +- .../sample/compose/ui/screens/main/second/SecondScreen.kt | 2 +- .../sample/compose/ui/screens/main/third/ThirdScreen.kt | 2 +- sample-compose/gradle/libs.versions.toml | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt index ae7502bf6..7212badd8 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt @@ -1,7 +1,7 @@ package co.nimblehq.sample.compose.di.modules.main import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.extensions.showToast import co.nimblehq.sample.compose.navigation.EntryProviderInstaller diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt index 713e56f70..a5c97954b 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.sample.compose.R import co.nimblehq.sample.compose.extensions.collectAsEffect diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt index 577e209e1..2b0a40a4e 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt index d1352926f..4accba9cd 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.Modifier 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 co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen diff --git a/sample-compose/gradle/libs.versions.toml b/sample-compose/gradle/libs.versions.toml index a70c25236..a7e69105b 100644 --- a/sample-compose/gradle/libs.versions.toml +++ b/sample-compose/gradle/libs.versions.toml @@ -14,7 +14,7 @@ detekt = "1.21.0" detektRules = "0.3.3" gradle = "8.9.1" hilt = "2.54" -hiltNavigation = "1.2.0" +hiltLifecycleViewmodel = "1.3.0" javaxInject = "1" junit = "4.13.2" kotest = "5.6.2" @@ -61,7 +61,7 @@ accompanist-systemUiController = { group = "com.google.accompanist", name = "acc # 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" } @@ -125,7 +125,7 @@ compose = [ ] hilt = [ "hilt-android", - "hilt-navigation", + "hilt-lifecycle-viewmodel-compose", ] retrofit = [ "retrofit", From 8d0ceae8f36748eea937755e9bb35ea330629478 Mon Sep 17 00:00:00 2001 From: Tiger Date: Mon, 16 Feb 2026 15:45:38 +0700 Subject: [PATCH 5/6] [#617] Fix test and add opt-ins --- .../co/nimblehq/sample/compose/ui/screens/MainActivity.kt | 2 +- .../co/nimblehq/sample/compose/util/DeepLinkMatcher.kt | 6 +++--- .../co/nimblehq/sample/compose/util/DeepLinkPattern.kt | 7 +++++-- .../sample/compose/ui/screens/main/home/HomeScreenTest.kt | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt index a4ac9aafe..b3e15a7b5 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt @@ -31,7 +31,7 @@ import co.nimblehq.sample.compose.util.ResultEventBus import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -private const val TWEEN_DURATION_IN_MILLIS = 500; +private const val TWEEN_DURATION_IN_MILLIS = 500 @AndroidEntryPoint class MainActivity : ComponentActivity() { diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt index 3d61ab678..4f339784d 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.KSerializer internal class DeepLinkMatcher( val request: DeepLinkRequest, - val deepLinkPattern: DeepLinkPattern + val deepLinkPattern: DeepLinkPattern, ) { /** * Match a [DeepLinkRequest] to a [DeepLinkPattern]. @@ -25,7 +25,7 @@ internal class DeepLinkMatcher( // zip to compare the two objects side by side, order matters here so we // need to make sure the compared segments are at the same position within the url .zip(deepLinkPattern.pathSegments.asSequence()) - .forEach { it -> + .forEach { // retrieve the two path segments to compare val requestedSegment = it.first val candidateSegment = it.second @@ -75,7 +75,7 @@ internal class DeepLinkMatcher( * */ internal data class DeepLinkMatchResult( val serializer: KSerializer, - val args: Map + val args: Map, ) const val TAG_LOG_ERROR = "SampleComposeDeepLink" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt index abc9f0c01..ea4614b03 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt @@ -1,6 +1,7 @@ package co.nimblehq.sample.compose.util import android.net.Uri +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.SerialKind @@ -35,9 +36,10 @@ import java.io.Serializable * @param serializer the serializer of [T] * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" */ +@OptIn(ExperimentalSerializationApi::class) internal class DeepLinkPattern( val serializer: KSerializer, - val uriPattern: Uri + val uriPattern: Uri, ) { /** * Help differentiate if a path segment is an argument or a static value @@ -89,7 +91,7 @@ internal class DeepLinkPattern( class PathSegment( val stringValue: String, val isParamArg: Boolean, - val typeParser: TypeParser + val typeParser: TypeParser, ) } @@ -98,6 +100,7 @@ internal class DeepLinkPattern( */ private typealias TypeParser = (String) -> Serializable +@OptIn(ExperimentalSerializationApi::class) private fun getTypeParser(kind: SerialKind): TypeParser { return when (kind) { PrimitiveKind.STRING -> Any::toString diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt index 53d16baa4..945beea90 100644 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt @@ -49,6 +49,7 @@ class HomeScreenTest : BaseScreenTest() { @Before fun setUp() { + ShadowToast.reset() every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs From 0c47ace1c2b8dc5cf582ff260704f8c86f1cd208 Mon Sep 17 00:00:00 2001 From: Tiger Date: Fri, 13 Feb 2026 09:20:40 +0700 Subject: [PATCH 6/6] [#617] Refactor screen examples and add more complex examples for deeplink and navigation based on POC --- .../ui/screens/main/home/HomeScreenTest.kt | 94 ----------- .../app/src/main/AndroidManifest.xml | 10 -- .../di/modules/main/MainActivityModule.kt | 112 +++++++++---- .../sample/compose/ui/common/AppBar.kt | 20 ++- .../sample/compose/ui/common/Constants.kt | 7 +- .../sample/compose/ui/screens/MainActivity.kt | 6 +- .../ui/screens/details/DetailsScreen.kt | 158 ++++++++++++++++++ .../ui/screens/details/DetailsViewModel.kt | 98 +++++++++++ .../ui/screens/{main/home => list}/Item.kt | 38 +---- .../screens/{main/home => list}/ItemList.kt | 5 +- .../home/HomeScreen.kt => list/ListScreen.kt} | 106 +++++++----- .../ListViewModel.kt} | 13 +- .../ui/screens/login/LoginOrRegisterScreen.kt | 112 +++++++++++++ .../compose/ui/screens/login/LoginScreen.kt | 103 ++++++++++++ .../ui/screens/main/MainDestination.kt | 16 -- .../ui/screens/main/second/SecondScreen.kt | 83 --------- .../ui/screens/main/second/SecondViewModel.kt | 8 - .../ui/screens/main/third/ThirdScreen.kt | 65 ------- .../ui/screens/main/third/ThirdViewModel.kt | 8 - .../compose/ui/screens/search/SearchScreen.kt | 134 +++++++++++++++ .../compose/util/AnnotatedStringUtil.kt | 35 ++++ .../sample/compose/util/ComposableUtil.kt | 2 - .../sample/compose/util/DeepLinkPattern.kt | 1 + .../app/src/main/res/values/strings.xml | 31 +++- .../ui/screens/main/home/HomeScreenTest.kt | 137 --------------- .../ui/screens/main/home/HomeViewModelTest.kt | 134 --------------- .../data/remote/models/responses/Response.kt | 2 +- .../data/remote/services/ApiService.kt | 8 + .../data/repositories/RepositoryImpl.kt | 9 + .../compose/domain/repositories/Repository.kt | 4 + .../domain/usecases/GetDetailsUseCase.kt | 13 ++ .../domain/usecases/SearchUserUseCase.kt | 13 ++ .../domain/usecases/GetDetailsUseCaseTest.kt | 50 ++++++ .../domain/usecases/SearchUserUseCaseTest.kt | 50 ++++++ sample-compose/gradle/libs.versions.toml | 2 + 35 files changed, 998 insertions(+), 689 deletions(-) delete mode 100644 sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt rename sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/{main/home => list}/Item.kt (50%) rename sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/{main/home => list}/ItemList.kt (86%) rename sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/{main/home/HomeScreen.kt => list/ListScreen.kt} (72%) rename sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/{main/home/HomeViewModel.kt => list/ListViewModel.kt} (85%) create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt delete mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt create mode 100644 sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt delete mode 100644 sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt delete mode 100644 sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt create mode 100644 sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt create mode 100644 sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt create mode 100644 sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt create mode 100644 sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt deleted file mode 100644 index 8dc73525f..000000000 --- a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import androidx.activity.compose.setContent -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.rule.GrantPermissionRule -import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase -import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.test.TestDispatchersProvider -import co.nimblehq.sample.compose.ui.screens.FakeNavigator -import co.nimblehq.sample.compose.ui.screens.MainActivity -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.theme.ComposeTheme -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -class HomeScreenTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - /** - * More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/ - */ - @get:Rule - val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.CAMERA - ) - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() - - private lateinit var viewModel: HomeViewModel - private lateinit var fakeNavigator: FakeNavigator - - @Before - fun setUp() { - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) - - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - TestDispatchersProvider - ) - } - - @Test - fun when_entering_the_Home_screen__it_shows_UI_correctly() = initComposable { - onNodeWithText("Home").assertIsDisplayed() - } - - @Test - fun when_loading_list_item_successfully__it_shows_the_list_item_correctly() = initComposable { - onNodeWithText("1").assertIsDisplayed() - onNodeWithText("2").assertIsDisplayed() - onNodeWithText("3").assertIsDisplayed() - } - - @Test - fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable { - onNodeWithText("1").performClick() - - assertEquals("1", (fakeNavigator.currentScreen() as? MainDestination.Second)?.id) - } - - private fun initComposable( - testBody: AndroidComposeTestRule, MainActivity>.() -> Unit - ) { - fakeNavigator = FakeNavigator() - composeRule.activity.setContent { - ComposeTheme { - HomeScreen( - viewModel = viewModel, - navigator = fakeNavigator - ) - } - } - testBody(composeRule) - } -} diff --git a/sample-compose/app/src/main/AndroidManifest.xml b/sample-compose/app/src/main/AndroidManifest.xml index ae40d2156..bd664f5b2 100644 --- a/sample-compose/app/src/main/AndroidManifest.xml +++ b/sample-compose/app/src/main/AndroidManifest.xml @@ -27,16 +27,6 @@ - - - - - - - - - - diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt index 7212badd8..f6a759a61 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt @@ -1,16 +1,21 @@ package co.nimblehq.sample.compose.di.modules.main +import android.content.Intent +import android.net.Uri import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.extensions.showToast import co.nimblehq.sample.compose.navigation.EntryProviderInstaller import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.navigation.NavigatorImpl -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.screens.main.home.HomeScreen -import co.nimblehq.sample.compose.ui.screens.main.second.SecondScreen -import co.nimblehq.sample.compose.ui.screens.main.third.ThirdScreen +import co.nimblehq.sample.compose.ui.common.PATH_BASE +import co.nimblehq.sample.compose.ui.common.PATH_SEARCH +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen +import co.nimblehq.sample.compose.ui.screens.details.DetailsViewModel +import co.nimblehq.sample.compose.ui.screens.list.ListScreen +import co.nimblehq.sample.compose.ui.screens.login.LoginOrRegisterScreen +import co.nimblehq.sample.compose.ui.screens.login.LoginScreen +import co.nimblehq.sample.compose.ui.screens.search.SearchScreen import co.nimblehq.sample.compose.util.LocalResultEventBus import co.nimblehq.sample.compose.util.ResultEffect import dagger.Module @@ -26,48 +31,91 @@ object MainActivityModule { @Provides @ActivityRetainedScoped - fun provideNavigator(): Navigator = NavigatorImpl(startDestination = MainDestination.Home) + fun provideNavigator(): Navigator = NavigatorImpl(startDestination = ListScreen) + @Suppress("LongMethod") @IntoSet @Provides fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = { - entry { - val eventBus = LocalResultEventBus.current + entry { + ListScreen( + viewModel = hiltViewModel(), + onClickSearch = { navigator.goTo(SearchScreen) }, + onItemClick = { uiModel -> + navigator.goTo(DetailsScreen.Details(uiModel.id.toIntOrNull() ?: 1)) + } + ) + } + + entry { val context = LocalContext.current + SearchScreen( + onClickCreateDeeplink = { username -> + val intent = Intent( + Intent.ACTION_VIEW, + "$PATH_BASE/$PATH_SEARCH?${DetailsScreen.Search::username.name}=${Uri.encode(username)}".toUri() + ) + context.startActivity(intent) + }, + onClickBack = navigator::goBack + ) + } - ResultEffect { isUpdated -> - if (isUpdated) { - context.showToast(context.getString(R.string.message_updated)) - } - eventBus.removeResult() + entry { key -> + val viewModel = hiltViewModel( + // Note: We need a new ViewModel for every new DetailsScreen instance. Usually + // we would need to supply a `key` String that is unique to the + // instance, however, the ViewModelStoreNavEntryDecorator (supplied + // above) does this for us, using `NavEntry.contentKey` to uniquely + // identify the viewModel. + // + // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator() + // if you want a new ViewModel for each new navigation key instance. + creationCallback = { factory -> factory.create(key) } + ) + val eventBus = LocalResultEventBus.current + + ResultEffect { username -> + viewModel.changeUsername(username) + eventBus.removeResult() } - HomeScreen( - viewModel = hiltViewModel(), - navigator = navigator + DetailsScreen( + viewModel = viewModel, + navigateToLoginOrRegister = { navigator.goTo(LoginOrRegisterScreen) }, + onClickBack = navigator::goBack ) } - entry { key -> - val eventBus = LocalResultEventBus.current + entry { key -> + val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(key) } + ) - SecondScreen( - id = key.id, - navigator = navigator, - viewModel = hiltViewModel(), - onUpdate = { - eventBus.sendResult(result = true) - navigator.goBack() - } + DetailsScreen( + viewModel = viewModel, + navigateToLoginOrRegister = { navigator.goTo(LoginOrRegisterScreen) }, + onClickBack = navigator::goBack ) } - entry { key -> - ThirdScreen( - model = key.model, - navigator = navigator, - viewModel = hiltViewModel() + entry { + LoginOrRegisterScreen( + navigateToLogin = { navigator.goTo(LoginScreen) }, + navigateToRegister = { /* NO-OP */ }, + onClickBack = navigator::goBack + ) + } + + entry { + val eventBus = LocalResultEventBus.current + LoginScreen( + navigateToDetails = { username -> + eventBus.sendResult(result = username) + navigator.goBackToLast(DetailsScreen::class) + }, + onClickBack = navigator::goBack ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt index 884dbee78..695da0f47 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt @@ -1,7 +1,12 @@ package co.nimblehq.sample.compose.ui.common import androidx.annotation.StringRes +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -18,10 +23,23 @@ import co.nimblehq.sample.compose.ui.theme.ComposeTheme fun AppBar( @StringRes title: Int, modifier: Modifier = Modifier, + onClickBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( modifier = modifier, title = { Text(text = stringResource(title)) }, + navigationIcon = { + if (onClickBack != null) { + IconButton(onClick = onClickBack) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + }, + actions = actions, colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = AppTheme.colors.topAppBarBackground ) @@ -31,5 +49,5 @@ fun AppBar( @Preview(showBackground = true) @Composable private fun AppBarPreview() { - ComposeTheme { AppBar(R.string.home_title_bar) } + ComposeTheme { AppBar(R.string.list_title) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt index 2ce039bd5..023dbd4bb 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt @@ -1,3 +1,8 @@ package co.nimblehq.sample.compose.ui.common -const val URL_SECOND_SCREEN = "https://sample.nimblehq.co/second?id={id}" +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen + +internal const val PATH_BASE = "android://android.nimblehq.co" +internal const val PATH_SEARCH = "users/search" +internal val URL_SEARCH = + "$PATH_BASE/$PATH_SEARCH?${DetailsScreen.Search::username.name}={${DetailsScreen.Search::username.name}}" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt index b3e15a7b5..5d718f932 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt @@ -19,8 +19,8 @@ import androidx.navigation3.ui.NavDisplay import co.nimblehq.sample.compose.extensions.setEdgeToEdgeConfig import co.nimblehq.sample.compose.navigation.EntryProviderInstaller import co.nimblehq.sample.compose.navigation.Navigator -import co.nimblehq.sample.compose.ui.common.URL_SECOND_SCREEN -import co.nimblehq.sample.compose.ui.screens.main.MainDestination +import co.nimblehq.sample.compose.ui.common.URL_SEARCH +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen import co.nimblehq.sample.compose.ui.theme.ComposeTheme import co.nimblehq.sample.compose.util.DeepLinkMatcher import co.nimblehq.sample.compose.util.DeepLinkPattern @@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() { lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> internal val deepLinkPatterns: List> = listOf( - DeepLinkPattern(MainDestination.Second.serializer(), URL_SECOND_SCREEN.toUri()), + DeepLinkPattern(DetailsScreen.Search.serializer(), URL_SEARCH.toUri()), ) override fun onCreate(savedInstanceState: Bundle?) { diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt new file mode 100644 index 000000000..21326a3a1 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt @@ -0,0 +1,158 @@ +package co.nimblehq.sample.compose.ui.screens.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.extensions.collectAsEffect +import co.nimblehq.sample.compose.lib.IsLoading +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.models.UiModel +import co.nimblehq.sample.compose.ui.showToast +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.coroutines.flow.collectLatest +import kotlinx.serialization.Serializable + +@Serializable +sealed class DetailsScreen : NavKey { + @Serializable + data class Details(val id: Int) : DetailsScreen() + + @Serializable + data class Search(val username: String) : DetailsScreen() +} + +@Composable +fun DetailsScreen( + viewModel: DetailsViewModel, + navigateToLoginOrRegister: () -> Unit, + onClickBack: () -> Unit, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + val context = LocalContext.current + viewModel.error.collectAsEffect { e -> e.showToast(context) } + + LaunchedEffect(Unit) { + viewModel.onLoginRequired.collectLatest { + navigateToLoginOrRegister() + } + } + + val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val uiModel: UiModel? by viewModel.uiModel.collectAsStateWithLifecycle() + val isLiked: Boolean by viewModel.isLiked.collectAsStateWithLifecycle() + val isFromDeepLink: Boolean by viewModel.isFromDeepLink.collectAsStateWithLifecycle() + val username: String? by viewModel.username.collectAsStateWithLifecycle() + + DetailsScreenContent( + uiModel = uiModel, + isLoading = isLoading, + isLiked = isLiked, + isFromDeepLink = isFromDeepLink, + username = username, + onClickBack = onClickBack, + onClickLike = viewModel::onClickLike, + ) +} + +@Composable +private fun DetailsScreenContent( + uiModel: UiModel?, + isLoading: IsLoading, + isLiked: Boolean, + isFromDeepLink: Boolean, + username: String?, + onClickBack: () -> Unit, + onClickLike: () -> Unit, +) { + Scaffold( + topBar = { + AppBar( + title = R.string.details_title, + onClickBack = onClickBack, + actions = { + if (!isFromDeepLink) { + IconButton(onClick = onClickLike) { + Icon( + imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + contentDescription = stringResource(R.string.test_tag_favorite_button) + ) + } + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + uiModel?.let { model -> + Column( + modifier = Modifier.padding(dimensions.spacingLarge) + ) { + Text(text = "ID: ${model.id}") + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + Text(text = "Username: ${model.username}") + + username?.let { + Spacer(modifier = Modifier.height(dimensions.spacingLarge)) + Text(text = stringResource(R.string.welcome_back, it)) + } + } + } ?: run { + Text( + text = "No data available", + modifier = Modifier + .align(Alignment.Center) + .padding(dimensions.spacingLarge) + ) + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun DetailsScreenPreview() { + ComposeTheme { + DetailsScreenContent( + uiModel = UiModel("1", "testuser"), + isLoading = false, + isLiked = false, + isFromDeepLink = false, + username = null, + onClickBack = {}, + onClickLike = {}, + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt new file mode 100644 index 000000000..f54e3f9ff --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt @@ -0,0 +1,98 @@ +package co.nimblehq.sample.compose.ui.screens.details + +import androidx.lifecycle.viewModelScope +import co.nimblehq.sample.compose.domain.usecases.GetDetailsUseCase +import co.nimblehq.sample.compose.domain.usecases.SearchUserUseCase +import co.nimblehq.sample.compose.ui.base.BaseViewModel +import co.nimblehq.sample.compose.ui.models.UiModel +import co.nimblehq.sample.compose.ui.models.toUiModel +import co.nimblehq.sample.compose.util.DispatchersProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class) +class DetailsViewModel @AssistedInject constructor( + @Assisted private val key: DetailsScreen, + private val getDetailsUseCase: GetDetailsUseCase, + private val searchUserUseCase: SearchUserUseCase, + private val dispatchersProvider: DispatchersProvider, +) : BaseViewModel() { + + private val _uiModel = MutableStateFlow(null) + val uiModel = _uiModel.asStateFlow() + + private val _isLiked = MutableStateFlow(false) + val isLiked = _isLiked.asStateFlow() + + private val _isFromDeepLink = MutableStateFlow(false) + val isFromDeepLink = _isFromDeepLink.asStateFlow() + + private val _username = MutableStateFlow(null) + val username = _username.asStateFlow() + + private val _onLoginRequired = MutableSharedFlow() + val onLoginRequired = _onLoginRequired.asSharedFlow() + + init { + when (key) { + is DetailsScreen.Details -> loadDetails(key.id) + is DetailsScreen.Search -> searchUser(key.username) + } + } + + private fun loadDetails(id: Int) { + getDetailsUseCase(id) + .injectLoading() + .onEach { model -> + _uiModel.emit(model.toUiModel()) + _isFromDeepLink.emit(false) + } + .flowOn(dispatchersProvider.io) + .catch { e -> _error.emit(e) } + .launchIn(viewModelScope) + } + + private fun searchUser(username: String) { + searchUserUseCase(username) + .injectLoading() + .onEach { models -> + _uiModel.emit(models.firstOrNull()?.toUiModel()) + _isFromDeepLink.emit(true) + } + .flowOn(dispatchersProvider.io) + .catch { e -> _error.emit(e) } + .launchIn(viewModelScope) + } + + fun onClickLike() { + launch { + if (!_isLiked.value) { + _onLoginRequired.emit(Unit) + } else { + _isLiked.emit(false) + } + } + } + + fun changeUsername(username: String) { + launch { + _username.emit(username) + _isLiked.emit(true) + } + } + + @AssistedFactory + interface Factory { + fun create(key: DetailsScreen): DetailsViewModel + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt similarity index 50% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt index a4d4d6fd1..262d2a5fe 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt @@ -1,36 +1,28 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.theme.* import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme -@OptIn(ExperimentalFoundationApi::class) @Composable fun Item( uiModel: UiModel, onClick: (UiModel) -> Unit, - onLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { - var expanded by remember { mutableStateOf(false) } - Box( modifier = modifier .fillMaxWidth() - .combinedClickable( - onClick = { onClick(uiModel) }, - onLongClick = { expanded = true } - ) + .clickable { onClick(uiModel) } ) { Row { Text( @@ -46,17 +38,6 @@ fun Item( text = uiModel.username ) } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(id = R.string.third_edit_menu_title)) }, - onClick = { onLongClick(uiModel) } - ) - - } } } @@ -67,7 +48,6 @@ private fun ItemPreview() { Item( uiModel = UiModel("1", "name1"), onClick = {}, - onLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt similarity index 86% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt index f2f6d60d7..7ec7a66dd 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt @@ -1,4 +1,4 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,7 +15,6 @@ import kotlinx.collections.immutable.persistentListOf fun ItemList( uiModels: ImmutableList, onItemClick: (UiModel) -> Unit, - onItemLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier) { @@ -23,7 +22,6 @@ fun ItemList( Item( uiModel = uiModel, onClick = onItemClick, - onLongClick = onItemLongClick ) HorizontalDivider() } @@ -37,7 +35,6 @@ private fun ItemListPreview() { ItemList( uiModels = persistentListOf(UiModel("1", "name1"), UiModel("2", "name2"), UiModel("3", "name3")), onItemClick = {}, - onItemLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt similarity index 72% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt index a5c97954b..d94e95733 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt @@ -1,10 +1,15 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import android.Manifest.permission.CAMERA +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -15,11 +20,11 @@ import androidx.compose.ui.platform.LocalContext 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.sample.compose.R import co.nimblehq.sample.compose.extensions.collectAsEffect import co.nimblehq.sample.compose.extensions.showToast import co.nimblehq.sample.compose.lib.IsLoading -import co.nimblehq.sample.compose.navigation.Navigator import co.nimblehq.sample.compose.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel @@ -31,17 +36,23 @@ import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +@Serializable +data object ListScreen : NavKey + +@SuppressLint("LocalContextGetResourceValueCall") @Composable -fun HomeScreen( - navigator: Navigator, - viewModel: HomeViewModel = hiltViewModel(), +fun ListScreen( + onClickSearch: () -> Unit, + onItemClick: (UiModel) -> Unit, + modifier: Modifier = Modifier, + viewModel: ListViewModel = hiltViewModel(), ) = BaseScreen( isDarkStatusBarIcons = true, ) { val context = LocalContext.current viewModel.error.collectAsEffect { e -> e.showToast(context) } - viewModel.navigator.collectAsEffect { destination -> navigator.goTo(destination) } val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() val uiModels: ImmutableList by viewModel.uiModels.collectAsStateWithLifecycle() @@ -56,47 +67,38 @@ fun HomeScreen( CameraPermission() - HomeScreenContent( + ListScreenContent( uiModels = uiModels, isLoading = isLoading, - onItemClick = viewModel::navigateToSecond, - onItemLongClick = viewModel::navigateToThird + onClickSearch = onClickSearch, + onItemClick = onItemClick, + modifier = modifier, ) } -/** - * [CameraPermission] needs to be separate from [HomeScreenContent] to avoid re-composition - */ -@OptIn(ExperimentalPermissionsApi::class) -@Composable -private fun CameraPermission() { - val context = LocalContext.current - val cameraPermissionState = rememberPermissionState(CAMERA) - if (cameraPermissionState.status.isGranted) { - context.showToast("${cameraPermissionState.permission} granted") - } else { - if (cameraPermissionState.status.shouldShowRationale) { - context.showToast("${cameraPermissionState.permission} needs rationale") - } else { - context.showToast("Request cancelled, missing permissions in manifest or denied permanently") - } - - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - } -} - @Composable -private fun HomeScreenContent( +private fun ListScreenContent( uiModels: ImmutableList, isLoading: IsLoading, + onClickSearch: () -> Unit, onItemClick: (UiModel) -> Unit, - onItemLongClick: (UiModel) -> Unit, + modifier: Modifier = Modifier, ) { - Scaffold(topBar = { - AppBar(R.string.home_title_bar) - }) { paddingValues -> + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.list_title, + actions = { + IconButton(onClick = onClickSearch) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search" + ) + } + } + ) + }) { paddingValues -> Box( modifier = Modifier .fillMaxSize() @@ -108,22 +110,44 @@ private fun HomeScreenContent( ItemList( uiModels = uiModels, onItemClick = onItemClick, - onItemLongClick = onItemLongClick ) } } } } +/** + * [CameraPermission] needs to be separate from [ListScreenContent] to avoid re-composition + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun CameraPermission() { + val context = LocalContext.current + val cameraPermissionState = rememberPermissionState(CAMERA) + if (cameraPermissionState.status.isGranted) { + context.showToast("${cameraPermissionState.permission} granted") + } else { + if (cameraPermissionState.status.shouldShowRationale) { + context.showToast("${cameraPermissionState.permission} needs rationale") + } else { + context.showToast("Request cancelled, missing permissions in manifest or denied permanently") + } + + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + } +} + @Preview(showSystemUi = true) @Composable -private fun HomeScreenPreview() { +private fun ListScreenPreview() { ComposeTheme { - HomeScreenContent( + ListScreenContent( uiModels = persistentListOf(UiModel("1", "name1"), UiModel("2", "name2"), UiModel("3", "name3")), isLoading = false, + onClickSearch = {}, onItemClick = {}, - onItemLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt similarity index 85% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt index 1dd9fa261..a7a2d80a0 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt @@ -1,4 +1,4 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import androidx.lifecycle.viewModelScope import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase @@ -7,7 +7,6 @@ import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferenc import co.nimblehq.sample.compose.ui.base.BaseViewModel import co.nimblehq.sample.compose.ui.models.UiModel import co.nimblehq.sample.compose.ui.models.toUiModel -import co.nimblehq.sample.compose.ui.screens.main.MainDestination import co.nimblehq.sample.compose.util.DispatchersProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList @@ -22,7 +21,7 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor( +class ListViewModel @Inject constructor( getModelsUseCase: GetModelsUseCase, isFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase, private val updateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase, @@ -61,12 +60,4 @@ class HomeViewModel @Inject constructor( _isFirstTimeLaunch.emit(false) } } - - fun navigateToSecond(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Second(uiModel.id)) } - } - - fun navigateToThird(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Third(uiModel)) } - } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt new file mode 100644 index 000000000..538e6e2a5 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt @@ -0,0 +1,112 @@ +package co.nimblehq.sample.compose.ui.screens.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import co.nimblehq.sample.compose.util.buildAnnotatedStringWithPart +import kotlinx.serialization.Serializable + +@Serializable +data object LoginOrRegisterScreen : NavKey + +@Composable +fun LoginOrRegisterScreen( + navigateToLogin: () -> Unit, + navigateToRegister: () -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.login, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = stringResource(R.string.login_required), + modifier = Modifier.padding(dimensions.spacingMedium) + ) + + Button( + onClick = navigateToLogin, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.login)) + } + + OutlinedButton( + onClick = navigateToRegister, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.register_here)) + } + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + val fullText = stringResource(R.string.do_you_have_account) + val clickablePart = stringResource(R.string.register_here) + val annotatedString = buildAnnotatedStringWithPart( + fullText = fullText, + clickablePart = clickablePart, + clickableStyle = SpanStyle( + color = Color.Blue, + fontWeight = FontWeight.Bold + ), + onClick = navigateToRegister, + ) + + Text(text = annotatedString) + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun LoginOrRegisterScreenPreview() { + ComposeTheme { + LoginOrRegisterScreen( + navigateToLogin = {}, + navigateToRegister = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt new file mode 100644 index 000000000..812aabb51 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt @@ -0,0 +1,103 @@ +package co.nimblehq.sample.compose.ui.screens.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen : NavKey + +@Composable +fun LoginScreen( + navigateToDetails: (String) -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + var username by remember { mutableStateOf("") } + + val usernameFieldTag = stringResource(R.string.test_tag_user_name_field) + val loginButtonTag = stringResource(R.string.test_tag_login_button) + + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.login, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.username)) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = usernameFieldTag } + ) + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + Button( + onClick = { navigateToDetails(username) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = loginButtonTag }, + enabled = username.isNotEmpty() + ) { + Text(stringResource(R.string.login)) + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun LoginScreenPreview() { + ComposeTheme { + LoginScreen( + navigateToDetails = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt deleted file mode 100644 index 0b04385e9..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt +++ /dev/null @@ -1,16 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main - -import co.nimblehq.sample.compose.ui.models.UiModel -import kotlinx.serialization.Serializable - -sealed interface MainDestination { - - @Serializable - data object Home : MainDestination - - @Serializable - data class Second(val id: String) : MainDestination - - @Serializable - data class Third(val model: UiModel) : MainDestination -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt deleted file mode 100644 index 2b0a40a4e..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt +++ /dev/null @@ -1,83 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.second - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.navigation.Navigator -import co.nimblehq.sample.compose.ui.base.BaseScreen -import co.nimblehq.sample.compose.ui.common.AppBar -import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions -import co.nimblehq.sample.compose.ui.theme.ComposeTheme - -@Suppress("UnusedPrivateMember") -@Composable -fun SecondScreen( - id: String, - navigator: Navigator, - viewModel: SecondViewModel = hiltViewModel(), - onUpdate: () -> Unit = {}, -) = BaseScreen( - isDarkStatusBarIcons = false, -) { - SecondScreenContent( - id = id, - onUpdateClick = onUpdate, - ) -} - -@Composable -private fun SecondScreenContent( - id: String, - onUpdateClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - AppBar(R.string.second_title_bar) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Column(modifier = Modifier.align(Alignment.Center)) { - Text( - text = stringResource(R.string.second_id_title, id), - ) - - Button( - onClick = { onUpdateClick() }, - modifier = Modifier.padding(dimensions.spacingMedium) - ) { - Text( - text = stringResource(R.string.second_update) - ) - } - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun SecondScreenPreview() { - ComposeTheme { - SecondScreenContent( - id = "1", - onUpdateClick = {}, - ) - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt deleted file mode 100644 index 3abb1ab4e..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.second - -import co.nimblehq.sample.compose.ui.base.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class SecondViewModel @Inject constructor() : BaseViewModel() diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt deleted file mode 100644 index 4accba9cd..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt +++ /dev/null @@ -1,65 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.third - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.navigation.Navigator -import co.nimblehq.sample.compose.ui.base.BaseScreen -import co.nimblehq.sample.compose.ui.common.AppBar -import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.theme.ComposeTheme - -@Suppress("UnusedPrivateMember") -@Composable -fun ThirdScreen( - model: UiModel?, - navigator: Navigator, - viewModel: ThirdViewModel = hiltViewModel(), -) = BaseScreen( - isDarkStatusBarIcons = true, -) { - ThirdScreenContent(data = model) -} - -@Composable -private fun ThirdScreenContent( - data: UiModel?, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - AppBar(title = R.string.third_title_bar) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Text( - text = stringResource(R.string.third_data_title, data.toString()), - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } - } -} - -@Preview -@Composable -private fun ThirdScreenPreview() { - ComposeTheme { - ThirdScreenContent(data = UiModel("1", "name1")) - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt deleted file mode 100644 index 60aea8133..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.third - -import co.nimblehq.sample.compose.ui.base.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class ThirdViewModel @Inject constructor() : BaseViewModel() diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt new file mode 100644 index 000000000..cc8af7de5 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt @@ -0,0 +1,134 @@ +package co.nimblehq.sample.compose.ui.screens.search + +import android.content.ClipData +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.common.PATH_BASE +import co.nimblehq.sample.compose.ui.common.PATH_SEARCH +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +data object SearchScreen : NavKey + +@Composable +fun SearchScreen( + onClickCreateDeeplink: (String) -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + var username by remember { mutableStateOf("") } + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.search_title, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.enter_username)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + Button( + onClick = { onClickCreateDeeplink(username) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.create_deeplink)) + } + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + // Show deeplink preview + if (username.isNotEmpty()) { + val deeplink = "$PATH_BASE/$PATH_SEARCH?username=$username" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Deeplink preview:\n$deeplink", + modifier = Modifier + .padding(dimensions.spacingMedium) + .weight(1f) + ) + IconButton(onClick = { + scope.launch { + clipboardManager.setClipEntry( + ClipData.newPlainText("deeplink", deeplink).toClipEntry(), + ) + } + }) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Copy deeplink", + ) + } + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun SearchScreenPreview() { + ComposeTheme { + SearchScreen( + onClickCreateDeeplink = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt new file mode 100644 index 000000000..f3dc0c342 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt @@ -0,0 +1,35 @@ +package co.nimblehq.sample.compose.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +fun buildAnnotatedStringWithPart( + fullText: String, + clickablePart: String, + clickableStyle: SpanStyle, + onClick: () -> Unit, +): AnnotatedString { + return buildAnnotatedString { + val startIndex = fullText.indexOf(clickablePart) + if (startIndex != -1) { + append(fullText.take(startIndex)) + withLink( + LinkAnnotation.Clickable( + tag = "CLICKABLE", + linkInteractionListener = { onClick() } + ) + ) { + withStyle(style = clickableStyle) { + append(clickablePart) + } + } + append(fullText.substring(startIndex + clickablePart.length)) + } else { + append(fullText) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt index 1a4c5dc6c..290a6030e 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt @@ -1,12 +1,10 @@ package co.nimblehq.sample.compose.util -import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import com.google.accompanist.systemuicontroller.rememberSystemUiController -@SuppressLint("ComposableNaming") @Composable fun StatusBarColor( color: Color, diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt index ea4614b03..8c479b617 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt @@ -100,6 +100,7 @@ internal class DeepLinkPattern( */ private typealias TypeParser = (String) -> Serializable +@Suppress(" ComplexMethod ") @OptIn(ExperimentalSerializationApi::class) private fun getTypeParser(kind: SerialKind): TypeParser { return when (kind) { diff --git a/sample-compose/app/src/main/res/values/strings.xml b/sample-compose/app/src/main/res/values/strings.xml index d21fee171..25e784bb5 100644 --- a/sample-compose/app/src/main/res/values/strings.xml +++ b/sample-compose/app/src/main/res/values/strings.xml @@ -2,17 +2,30 @@ Sample Compose Unexpected error + This is the first time launch - Home + + List + Details + Search - Second - ID: %1$s - Update + + Enter username + Create Deeplink - Third - Data: %s - Edit + + Login required to like + Welcome back, %s! - This is the first time launch - Updated! + + Do you have an account? + Login + Register here + Username + + + Back + Favorite + Username Field + Login Button diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt deleted file mode 100644 index 945beea90..000000000 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import androidx.activity.compose.setContent -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.rule.GrantPermissionRule -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.domain.usecases.* -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.ui.screens.BaseScreenTest -import co.nimblehq.sample.compose.ui.screens.FakeNavigator -import co.nimblehq.sample.compose.ui.screens.MainActivity -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.theme.ComposeTheme -import io.kotest.matchers.shouldBe -import io.mockk.* -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import org.junit.* -import org.junit.Assert.assertEquals -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.shadows.ShadowToast - -@RunWith(RobolectricTestRunner::class) -class HomeScreenTest : BaseScreenTest() { - - @get:Rule - val composeRule = createAndroidComposeRule() - - /** - * More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/ - */ - @get:Rule - val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.CAMERA - ) - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = - mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = - mockk() - - private lateinit var viewModel: HomeViewModel - private lateinit var fakeNavigator: FakeNavigator - - @Before - fun setUp() { - ShadowToast.reset() - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) - coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs - } - - @Test - fun `When entering the Home screen for the first time, it shows a toast confirming that`() { - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(true) - - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.message_first_time_launch)) shouldBe true - } - } - - @Test - fun `When entering the Home screen NOT for the first time, it doesn't show the toast confirming that`() { - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.message_first_time_launch)) shouldBe false - } - } - - @Test - fun `When entering the Home screen and loading the list item successfully, it shows the list item correctly`() = - initComposable { - onNodeWithText("Home").assertIsDisplayed() - - onNodeWithText("1").assertIsDisplayed() - onNodeWithText("2").assertIsDisplayed() - onNodeWithText("3").assertIsDisplayed() - } - - @Test - fun `When entering the Home screen and loading the list item failure, it shows the corresponding error`() { - setStandardTestDispatcher() - - val error = Exception() - every { mockGetModelsUseCase() } returns flow { throw error } - - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.error_generic)) shouldBe true - } - } - - @Test - fun `When clicking on a list item, it navigates to Second screen`() = initComposable { - onNodeWithText("1").performClick() - - assertEquals("1", (fakeNavigator.currentScreen() as? MainDestination.Second)?.id) - } - - private fun initComposable( - testBody: AndroidComposeTestRule, MainActivity>.() -> Unit, - ) { - initViewModel() - fakeNavigator = FakeNavigator() - - composeRule.activity.setContent { - ComposeTheme { - HomeScreen( - viewModel = viewModel, - navigator = fakeNavigator, - ) - } - } - testBody(composeRule) - } - - private fun initViewModel() { - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - coroutinesRule.testDispatcherProvider - ) - } -} diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt deleted file mode 100644 index d6bc6a3d7..000000000 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import app.cash.turbine.test -import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase -import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.test.CoroutineTestRule -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.models.toUiModel -import co.nimblehq.sample.compose.util.DispatchersProvider -import io.kotest.matchers.shouldBe -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class HomeViewModelTest { - - @get:Rule - val coroutinesRule = CoroutineTestRule() - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() - - private lateinit var viewModel: HomeViewModel - - private val isFirstTimeLaunch = false - - @Before - fun setUp() { - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(isFirstTimeLaunch) - coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs - - initViewModel() - } - - @Test - fun `When loading models successfully, it shows the model list`() = runTest { - viewModel.uiModels.test { - expectMostRecentItem() shouldBe MockUtil.models.map { it.toUiModel() } - } - } - - @Test - fun `When loading models failed, it shows the corresponding error`() = runTest { - val error = Exception() - every { mockGetModelsUseCase() } returns flow { throw error } - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.error.test { - advanceUntilIdle() - - expectMostRecentItem() shouldBe error - } - } - - @Test - fun `When loading models, it shows and hides loading correctly`() = runTest { - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.isLoading.test { - awaitItem() shouldBe false - awaitItem() shouldBe true - awaitItem() shouldBe false - } - } - - @Test - fun `When calling navigate to Second, it navigates to Second screen`() = runTest { - val uiModel = MockUtil.models[0].toUiModel() - viewModel.navigator.test { - viewModel.navigateToSecond(uiModel) - - expectMostRecentItem() shouldBe MainDestination.Second(uiModel.id) - } - } - - @Test - fun `When initializing the ViewModel, it emits whether the app is launched for the first time accordingly`() = - runTest { - viewModel.isFirstTimeLaunch.first() shouldBe isFirstTimeLaunch - } - - @Test - fun `When initializing the ViewModel and isFirstTimeLaunchPreferencesUseCase returns error, it shows the corresponding error`() = - runTest { - val error = Exception() - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flow { throw error } - - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.error.test { - advanceUntilIdle() - - expectMostRecentItem() shouldBe error - } - } - - @Test - fun `When launching the app for the first time, it executes the use case and emits value accordingly`() = - runTest { - viewModel.onFirstTimeLaunch() - - coVerify(exactly = 1) { - mockUpdateFirstTimeLaunchPreferencesUseCase(false) - } - viewModel.isFirstTimeLaunch.first() shouldBe false - } - - private fun initViewModel(dispatchers: DispatchersProvider = coroutinesRule.testDispatcherProvider) { - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - dispatchers - ) - } -} diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt index d0a0ee510..456b5d339 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt @@ -11,7 +11,7 @@ data class Response( val userName: String?, ) -private fun Response.toModel() = Model( +fun Response.toModel() = Model( id = this.id, username = this.userName, ) diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt index 2eb702d9e..434f4bf22 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt @@ -2,9 +2,17 @@ package co.nimblehq.sample.compose.data.remote.services import co.nimblehq.sample.compose.data.remote.models.responses.Response import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query interface ApiService { @GET("users") suspend fun getResponses(): List + + @GET("users/{id}") + suspend fun getDetails(@Path("id") id: Int): Response + + @GET("users") + suspend fun searchUser(@Query("username") username: String): List } diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt index eb939659a..209744d85 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt @@ -1,6 +1,7 @@ package co.nimblehq.sample.compose.data.repositories import co.nimblehq.sample.compose.data.extensions.flowTransform +import co.nimblehq.sample.compose.data.remote.models.responses.toModel import co.nimblehq.sample.compose.data.remote.models.responses.toModels import co.nimblehq.sample.compose.data.remote.services.ApiService import co.nimblehq.sample.compose.domain.models.Model @@ -14,4 +15,12 @@ class RepositoryImpl constructor( override fun getModels(): Flow> = flowTransform { apiService.getResponses().toModels() } + + override fun getDetails(id: Int): Flow = flowTransform { + apiService.getDetails(id).toModel() + } + + override fun searchUser(username: String): Flow> = flowTransform { + apiService.searchUser(username).toModels() + } } diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt index 47c77b39d..fa1418ea8 100644 --- a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt @@ -6,4 +6,8 @@ import kotlinx.coroutines.flow.Flow interface Repository { fun getModels(): Flow> + + fun getDetails(id: Int): Flow + + fun searchUser(username: String): Flow> } diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt new file mode 100644 index 000000000..cb1928956 --- /dev/null +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt @@ -0,0 +1,13 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.models.Model +import co.nimblehq.sample.compose.domain.repositories.Repository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetDetailsUseCase @Inject constructor(private val repository: Repository) { + + operator fun invoke(id: Int): Flow { + return repository.getDetails(id) + } +} diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt new file mode 100644 index 000000000..83a2a5454 --- /dev/null +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt @@ -0,0 +1,13 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.models.Model +import co.nimblehq.sample.compose.domain.repositories.Repository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SearchUserUseCase @Inject constructor(private val repository: Repository) { + + operator fun invoke(username: String): Flow> { + return repository.searchUser(username) + } +} diff --git a/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt new file mode 100644 index 000000000..8ad6fb961 --- /dev/null +++ b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt @@ -0,0 +1,50 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.repositories.Repository +import co.nimblehq.sample.compose.domain.test.MockUtil +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class GetDetailsUseCaseTest { + + private lateinit var mockRepository: Repository + private lateinit var getDetailsUseCase: GetDetailsUseCase + + @Before + fun setUp() { + mockRepository = mockk() + getDetailsUseCase = GetDetailsUseCase(mockRepository) + } + + @Test + fun `When request successful, it returns success`() = runTest { + val expected = MockUtil.models.first() + val id = 1 + every { mockRepository.getDetails(id) } returns flowOf(expected) + + getDetailsUseCase(id).collect { + it shouldBe expected + } + } + + @Test + fun `When request failed, it returns error`() = runTest { + val expected = Exception() + val id = 1 + every { mockRepository.getDetails(id) } returns flow { throw expected } + + getDetailsUseCase(id).catch { + it shouldBe expected + }.collect() + } +} diff --git a/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt new file mode 100644 index 000000000..9158534b5 --- /dev/null +++ b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt @@ -0,0 +1,50 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.repositories.Repository +import co.nimblehq.sample.compose.domain.test.MockUtil +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class SearchUserUseCaseTest { + + private lateinit var mockRepository: Repository + private lateinit var searchUserUseCase: SearchUserUseCase + + @Before + fun setUp() { + mockRepository = mockk() + searchUserUseCase = SearchUserUseCase(mockRepository) + } + + @Test + fun `When request successful, it returns success`() = runTest { + val expected = MockUtil.models + val username = "name1" + every { mockRepository.searchUser(username) } returns flowOf(expected) + + searchUserUseCase(username).collect { + it shouldBe expected + } + } + + @Test + fun `When request failed, it returns error`() = runTest { + val expected = Exception() + val username = "name1" + every { mockRepository.searchUser(username) } returns flow { throw expected } + + searchUserUseCase(username).catch { + it shouldBe expected + }.collect() + } +} diff --git a/sample-compose/gradle/libs.versions.toml b/sample-compose/gradle/libs.versions.toml index a7e69105b..96f6397fd 100644 --- a/sample-compose/gradle/libs.versions.toml +++ b/sample-compose/gradle/libs.versions.toml @@ -56,6 +56,7 @@ compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } accompanist-systemUiController = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } @@ -122,6 +123,7 @@ compose = [ "compose-ui-tooling-preview", "compose-foundation", "compose-material3", + "compose-material-icons-core", ] hilt = [ "hilt-android",