diff --git a/sample-compose/app/build.gradle.kts b/sample-compose/app/build.gradle.kts index 031b50943..4447247ee 100644 --- a/sample-compose/app/build.gradle.kts +++ b/sample-compose/app/build.gradle.kts @@ -36,7 +36,7 @@ android { targetSdk = libs.versions.androidTargetSdk.get().toInt() versionCode = libs.versions.androidVersionCode.get().toInt() versionName = libs.versions.androidVersionName.get() - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "co.nimblehq.sample.compose.HiltTestRunner" } buildTypes { @@ -155,6 +155,9 @@ dependencies { androidTestImplementation(libs.test.compose.ui.junit4) androidTestImplementation(libs.test.rules) androidTestImplementation(libs.test.mockk.android) + androidTestImplementation(libs.test.navigation) + androidTestImplementation(libs.test.hilt.android) + kspAndroidTest(libs.test.hilt.android.kotlin) // Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] // cmp=co.nimblehq.sample.compose/androidx.activity.ComponentActivity } -- diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/HiltTestRunner.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/HiltTestRunner.kt new file mode 100644 index 000000000..e30481a63 --- /dev/null +++ b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/HiltTestRunner.kt @@ -0,0 +1,16 @@ +package co.nimblehq.sample.compose + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} 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..18202e140 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 @@ -1,11 +1,16 @@ package co.nimblehq.sample.compose.ui.screens.main.home import androidx.activity.compose.setContent +import androidx.compose.ui.platform.LocalContext 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.longClick import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.rule.GrantPermissionRule import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase @@ -13,22 +18,28 @@ 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.AppNavGraph 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 dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest 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 +@HiltAndroidTest class HomeScreenTest { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) - @get:Rule + @get:Rule(order = 1) val composeRule = createAndroidComposeRule() + private lateinit var navController: TestNavHostController /** * More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/ @@ -38,24 +49,27 @@ class HomeScreenTest { android.Manifest.permission.CAMERA ) - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() + private val mockGetModelsUseCase: GetModelsUseCase = mockk(relaxed = true) + private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk(relaxed = true) + private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = + mockk(relaxed = true) - private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null + // Cannot mock viewModel with mockk here because it will throw ClassCastException + // Ref: https://github.com/mockk/mockk/issues/321 + @BindValue + val viewModel: HomeViewModel = HomeViewModel( + mockGetModelsUseCase, + mockIsFirstTimeLaunchPreferencesUseCase, + mockUpdateFirstTimeLaunchPreferencesUseCase, + TestDispatchersProvider + ) @Before fun setUp() { + hiltRule.inject() + every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) - - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - TestDispatchersProvider - ) } @Test @@ -71,23 +85,52 @@ class HomeScreenTest { } @Test - fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable { + fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposableNavigation { onNodeWithText("1").performClick() - assertEquals(expectedDestination, MainDestination.Second) + onNodeWithText("Second").assertIsDisplayed() + + navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Second.route, null) + } + + @Test + fun when_long_clicking_on_a_list_item_and_click_edit__it_navigates_to_Third_screen() = initComposableNavigation { + onNodeWithText("1").performTouchInput { longClick() } + + onNodeWithText("Edit").performClick() + + onNodeWithText("Third").assertIsDisplayed() + + navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Third.route, null) } private fun initComposable( testBody: AndroidComposeTestRule, MainActivity>.() -> Unit ) { composeRule.activity.setContent { + navController = TestNavHostController(LocalContext.current) + navController.navigatorProvider.addNavigator(ComposeNavigator()) ComposeTheme { HomeScreen( viewModel = viewModel, - navigator = { destination -> expectedDestination = destination } + onNavigateToSecondScreen = {}, + onNavigateToThirdScreen = {}, ) } } testBody(composeRule) } + + private fun initComposableNavigation( + testBody: AndroidComposeTestRule, MainActivity>.() -> Unit + ) { + composeRule.activity.setContent { + navController = TestNavHostController(LocalContext.current) + navController.navigatorProvider.addNavigator(ComposeNavigator()) + ComposeTheme { + AppNavGraph(navController) + } + } + testBody(composeRule) + } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavHostControllerExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavHostControllerExt.kt new file mode 100644 index 000000000..65a159188 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavHostControllerExt.kt @@ -0,0 +1,65 @@ +package co.nimblehq.sample.compose.extensions + +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import co.nimblehq.sample.compose.ui.base.BaseAppDestination +import kotlin.collections.component1 +import kotlin.collections.component2 + +/** + * Use this extension or [navigate(BaseAppDestination.Up())] to prevent duplicated navigation events + */ +fun NavHostController.navigateAppDestinationUp() { + navigateTo(BaseAppDestination.Up()) +} + +private const val IntervalInMillis: Long = 1000L +private var lastNavigationEventExecutedTimeInMillis: Long = 0L + +/** + * Use this extension to prevent duplicated navigation events with the same destination in a short time + */ +private fun NavHostController.throttleNavigation( + appDestination: BaseAppDestination, + onNavigate: () -> Unit, +) { + val currentTime = System.currentTimeMillis() + if (currentBackStackEntry?.destination?.route == appDestination.route + && (currentTime - lastNavigationEventExecutedTimeInMillis < IntervalInMillis) + ) { + return + } + lastNavigationEventExecutedTimeInMillis = currentTime + + onNavigate() +} + +/** + * Navigate to provided [BaseAppDestination] + * 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.navigateTo( + appDestination: T, + builder: (NavOptionsBuilder.() -> Unit)? = null, +) = throttleNavigation(appDestination) { + when (appDestination) { + is BaseAppDestination.Up -> { + appDestination.results.forEach { (key, value) -> + previousBackStackEntry?.savedStateHandle?.set(key, value) + } + navigateUp() + } + else -> { + appDestination.parcelableArgument?.let { (key, value) -> + currentBackStackEntry?.savedStateHandle?.set(key, value) + } + navigate(route = appDestination.destination) { + if (builder != null) { + builder() + } + } + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavigationExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavigationExt.kt new file mode 100644 index 000000000..4708f499f --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/NavigationExt.kt @@ -0,0 +1,82 @@ +package co.nimblehq.sample.compose.extensions + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import co.nimblehq.sample.compose.ui.base.BaseAppDestination + +private const val NavAnimationDurationInMillis = 300 + +fun AnimatedContentTransitionScope.enterSlideInLeftTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutLeftTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.enterSlideInRightTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutRightTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.enterSlideInUpTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutDownTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun NavGraphBuilder.composable( + destination: BaseAppDestination, + deepLinks: List = emptyList(), + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = { + enterSlideInLeftTransition() + }, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = { + exitSlideOutLeftTransition() + }, + popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = { + enterSlideInRightTransition() + }, + popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = { + exitSlideOutRightTransition() + }, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + route = destination.route, + arguments = destination.arguments, + deepLinks = deepLinks, + enterTransition = enterTransition, + exitTransition = exitTransition, + popEnterTransition = popEnterTransition, + popExitTransition = popExitTransition, + content = content + ) +} 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 index 325e9c7cd..7a5d1dfc0 100644 --- 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 @@ -1,10 +1,10 @@ package co.nimblehq.sample.compose.ui -import co.nimblehq.sample.compose.ui.base.BaseDestination +import co.nimblehq.sample.compose.ui.base.BaseAppDestination sealed class AppDestination { - object RootNavGraph : BaseDestination("rootNavGraph") + object RootNavGraph : BaseAppDestination("rootNavGraph") - object MainNavGraph : BaseDestination("mainNavGraph") + object MainNavGraph : BaseAppDestination("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 index 4782b215d..35b0c6e9a 100644 --- 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 @@ -1,13 +1,8 @@ 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 @@ -22,42 +17,3 @@ fun AppNavGraph( 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/BaseAppDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseAppDestination.kt new file mode 100644 index 000000000..603a62404 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseAppDestination.kt @@ -0,0 +1,32 @@ +package co.nimblehq.sample.compose.ui.base + +import androidx.navigation.NamedNavArgument + +const val KeyResultOk = "keyResultOk" + +/** + * Use "class" over "object" for destinations with [parcelableArgument] usage or a [navArgument] with [defaultValue] set + * to reset destination nav arguments. + */ +abstract class BaseAppDestination(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? = null + + data class Up( + val results: HashMap = hashMapOf(), + ) : BaseAppDestination() { + + fun put(key: String, value: Any) = apply { + results[key] = value + } + } +} 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/BaseViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt index ad7649708..57eca9285 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,9 +18,6 @@ abstract class BaseViewModel : ViewModel() { protected val _error = MutableSharedFlow() val error = _error.asSharedFlow() - protected val _navigator = MutableSharedFlow() - val navigator = _navigator.asSharedFlow() - /** * To show loading manually, should call `hideLoading` after */ 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..e256a5343 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 @@ -2,7 +2,7 @@ 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.base.BaseAppDestination import co.nimblehq.sample.compose.ui.models.UiModel const val KeyId = "id" @@ -10,9 +10,9 @@ const val KeyModel = "model" sealed class MainDestination { - object Home : BaseDestination("home") + object Home : BaseAppDestination("home") - object Second : BaseDestination("second/{$KeyId}") { + object Second : BaseAppDestination("second/{$KeyId}") { override val arguments = listOf( navArgument(KeyId) { type = NavType.StringType } @@ -23,7 +23,7 @@ sealed class MainDestination { } } - object Third : BaseDestination("third") { + object Third : BaseAppDestination("third") { fun addParcel(value: UiModel) = apply { parcelableArgument = KeyModel to value } 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 index 1632ceaad..4c746288d 100644 --- 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 @@ -3,12 +3,13 @@ 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.composable import co.nimblehq.sample.compose.extensions.getThenRemove +import co.nimblehq.sample.compose.extensions.navigateTo import co.nimblehq.sample.compose.ui.AppDestination +import co.nimblehq.sample.compose.ui.base.BaseAppDestination 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 @@ -24,23 +25,31 @@ fun NavGraphBuilder.mainNavGraph( val isResultOk = backStackEntry.savedStateHandle .getThenRemove(KeyResultOk) ?: false HomeScreen( - navigator = { destination -> - navController.navigate(destination, destination.parcelableArgument) - }, isResultOk = isResultOk, + onNavigateToSecondScreen = { + navController.navigateTo(MainDestination.Second.createRoute(it)) + }, + onNavigateToThirdScreen = { + navController.navigateTo(MainDestination.Third.addParcel(it)) + } ) } composable(destination = MainDestination.Second) { backStackEntry -> SecondScreen( - navigator = { destination -> navController.navigate(destination) }, - id = backStackEntry.arguments?.getString(KeyId).orEmpty() + id = backStackEntry.arguments?.getString(KeyId).orEmpty(), + onClickUpdate = { + navController.navigateTo( + BaseAppDestination.Up().apply { + put(KeyResultOk, true) + } + ) + }, ) } 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 24650185e..1fb73a4f2 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 @@ -15,7 +15,6 @@ 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.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel @@ -26,18 +25,18 @@ import com.google.accompanist.permissions.* @Composable fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), - navigator: (destination: BaseDestination) -> Unit, + onNavigateToSecondScreen: (id: String) -> Unit, + onNavigateToThirdScreen: (UiModel) -> Unit, isResultOk: Boolean = false, ) = BaseScreen( isDarkStatusBarIcons = true, ) { val context = LocalContext.current viewModel.error.collectAsEffect { e -> e.showToast(context) } - viewModel.navigator.collectAsEffect { destination -> navigator(destination) } - val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() - val uiModels: List by viewModel.uiModels.collectAsStateWithLifecycle() - val isFirstTimeLaunch: Boolean by viewModel.isFirstTimeLaunch.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val uiModels by viewModel.uiModels.collectAsStateWithLifecycle() + val isFirstTimeLaunch by viewModel.isFirstTimeLaunch.collectAsStateWithLifecycle() LaunchedEffect(isFirstTimeLaunch) { if (isFirstTimeLaunch) { @@ -47,6 +46,8 @@ fun HomeScreen( } LaunchedEffect(Unit) { + viewModel.getModels() + viewModel.checkIfFirstTimeLaunch() if (isResultOk) { context.showToast(context.getString(R.string.message_updated)) } @@ -57,8 +58,8 @@ fun HomeScreen( HomeScreenContent( uiModels = uiModels, isLoading = isLoading, - onItemClick = viewModel::navigateToSecond, - onItemLongClick = viewModel::navigateToThird + onItemClick = onNavigateToSecondScreen, + onItemLongClick = onNavigateToThirdScreen ) } @@ -89,7 +90,7 @@ private fun CameraPermission() { private fun HomeScreenContent( uiModels: List, isLoading: IsLoading, - onItemClick: (UiModel) -> Unit, + onItemClick: (id: String) -> Unit, onItemLongClick: (UiModel) -> Unit, ) { Scaffold(topBar = { 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 593191106..ac219495b 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 @@ -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.coroutines.flow.MutableStateFlow @@ -20,8 +19,8 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - getModelsUseCase: GetModelsUseCase, - isFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase, + private val getModelsUseCase: GetModelsUseCase, + private val isFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase, private val updateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase, private val dispatchersProvider: DispatchersProvider, ) : BaseViewModel() { @@ -32,7 +31,7 @@ class HomeViewModel @Inject constructor( private val _isFirstTimeLaunch = MutableStateFlow(false) val isFirstTimeLaunch = _isFirstTimeLaunch.asStateFlow() - init { + fun getModels() { getModelsUseCase() .injectLoading() .onEach { result -> @@ -42,7 +41,9 @@ class HomeViewModel @Inject constructor( .flowOn(dispatchersProvider.io) .catch { e -> _error.emit(e) } .launchIn(viewModelScope) + } + fun checkIfFirstTimeLaunch() { isFirstTimeLaunchPreferencesUseCase() .onEach { isFirstTimeLaunch -> _isFirstTimeLaunch.emit(isFirstTimeLaunch) @@ -58,12 +59,4 @@ class HomeViewModel @Inject constructor( _isFirstTimeLaunch.emit(false) } } - - fun navigateToSecond(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Second.createRoute(uiModel.id)) } - } - - fun navigateToThird(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Third.addParcel(uiModel)) } - } } 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/main/home/Item.kt index a4d4d6fd1..6112cee53 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/main/home/Item.kt @@ -18,7 +18,7 @@ import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions @Composable fun Item( uiModel: UiModel, - onClick: (UiModel) -> Unit, + onClick: (id: String) -> Unit, onLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { @@ -28,7 +28,7 @@ fun Item( modifier = modifier .fillMaxWidth() .combinedClickable( - onClick = { onClick(uiModel) }, + onClick = { onClick(uiModel.id) }, onLongClick = { expanded = true } ) ) { 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/main/home/ItemList.kt index 18b4248f0..39383decf 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/main/home/ItemList.kt @@ -12,7 +12,7 @@ import co.nimblehq.sample.compose.ui.theme.ComposeTheme @Composable fun ItemList( uiModels: List, - onItemClick: (UiModel) -> Unit, + onItemClick: (id: String) -> Unit, onItemLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { 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 725268923..2b594fb6e 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,9 +14,7 @@ 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.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 @@ -24,16 +22,14 @@ import co.nimblehq.sample.compose.ui.theme.ComposeTheme @Composable fun SecondScreen( viewModel: SecondViewModel = hiltViewModel(), - navigator: (destination: BaseDestination) -> Unit, + onClickUpdate: () -> Unit, id: String, ) = BaseScreen( isDarkStatusBarIcons = false, ) { SecondScreenContent( id = id, - onUpdateClick = { - navigator(BaseDestination.Up().addResult(KeyResultOk, true)) - }, + onUpdateClick = onClickUpdate, ) } 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 4776a9305..7710f8b48 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,7 +13,6 @@ 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.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel @@ -22,7 +21,6 @@ import co.nimblehq.sample.compose.ui.theme.ComposeTheme @Composable fun ThirdScreen( viewModel: ThirdViewModel = hiltViewModel(), - navigator: (destination: BaseDestination) -> Unit, model: UiModel?, ) = BaseScreen( isDarkStatusBarIcons = true, 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 3a1b9569a..1ce96f13e 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,17 +9,15 @@ 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.models.UiModel import co.nimblehq.sample.compose.ui.screens.BaseScreenTest 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 @@ -43,15 +41,18 @@ class HomeScreenTest : BaseScreenTest() { mockk() private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() + private val mockOnNavigateToSecondScreen: (String) -> Unit = mockk() + private val mockOnNavigateToThirdScreen: (UiModel) -> Unit = mockk() private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null @Before fun setUp() { every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs + every { mockOnNavigateToSecondScreen(any()) } just Runs + every { mockOnNavigateToThirdScreen(any()) } just Runs } @Test @@ -105,7 +106,7 @@ class HomeScreenTest : BaseScreenTest() { fun `When clicking on a list item, it navigates to Second screen`() = initComposable { onNodeWithText("1").performClick() - assertEquals(expectedDestination, MainDestination.Second) + verify(exactly = 1) { mockOnNavigateToSecondScreen(any()) } } private fun initComposable( @@ -117,7 +118,8 @@ class HomeScreenTest : BaseScreenTest() { ComposeTheme { HomeScreen( viewModel = viewModel, - navigator = { destination -> expectedDestination = destination }, + onNavigateToSecondScreen = mockOnNavigateToSecondScreen, + onNavigateToThirdScreen = mockOnNavigateToThirdScreen, ) } } 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..4500b8591 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 @@ -6,7 +6,6 @@ import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUs 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 @@ -52,6 +51,8 @@ class HomeViewModelTest { @Test fun `When loading models successfully, it shows the model list`() = runTest { + viewModel.getModels() + viewModel.uiModels.test { expectMostRecentItem() shouldBe MockUtil.models.map { it.toUiModel() } } @@ -63,6 +64,8 @@ class HomeViewModelTest { every { mockGetModelsUseCase() } returns flow { throw error } initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) + viewModel.getModels() + viewModel.error.test { advanceUntilIdle() @@ -74,6 +77,8 @@ class HomeViewModelTest { fun `When loading models, it shows and hides loading correctly`() = runTest { initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) + viewModel.getModels() + viewModel.isLoading.test { awaitItem() shouldBe false awaitItem() shouldBe true @@ -81,15 +86,6 @@ class HomeViewModelTest { } } - @Test - fun `When calling navigate to Second, it navigates to Second screen`() = runTest { - viewModel.navigator.test { - viewModel.navigateToSecond(MockUtil.models[0].toUiModel()) - - expectMostRecentItem() shouldBe MainDestination.Second - } - } - @Test fun `When initializing the ViewModel, it emits whether the app is launched for the first time accordingly`() = runTest { @@ -104,6 +100,8 @@ class HomeViewModelTest { initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) + viewModel.checkIfFirstTimeLaunch() + viewModel.error.test { advanceUntilIdle() @@ -114,6 +112,8 @@ class HomeViewModelTest { @Test fun `When launching the app for the first time, it executes the use case and emits value accordingly`() = runTest { + viewModel.checkIfFirstTimeLaunch() + viewModel.onFirstTimeLaunch() coVerify(exactly = 1) { diff --git a/sample-compose/gradle/libs.versions.toml b/sample-compose/gradle/libs.versions.toml index 65bf5d243..aaea2fd2f 100644 --- a/sample-compose/gradle/libs.versions.toml +++ b/sample-compose/gradle/libs.versions.toml @@ -9,12 +9,13 @@ accompanist = "0.30.1" chucker = "4.0.0" composeBom = "2025.02.00" # @kaungkhantsoe Will update in a separate PR -composeNavigation = "2.5.3" +composeNavigation = "2.8.9" core = "1.15.0" datastore = "1.1.2" detekt = "1.21.0" gradle = "8.8.1" -hilt = "2.52" +# https://github.com/google/dagger/issues/4451 +hilt = "2.53" hiltNavigation = "1.2.0" javaxInject = "1" junit = "4.13.2" @@ -24,7 +25,7 @@ kotlinxCoroutines = "1.7.3" kover = "0.7.3" ksp = "2.1.0-1.0.29" lifecycle = "2.8.7" -mockk = "1.13.8" +mockk = "1.13.17" moshi = "1.15.1" nimbleCommon = "0.1.2" okhttp = "4.12.0" @@ -98,6 +99,9 @@ test-rules = { group = "androidx.test", name = "rules", version.ref = "testRules # Ref: https://github.com/mockk/mockk/issues/325#issuecomment-549027350 test-mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } test-compose-ui-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +test-navigation = { group = "androidx.navigation", name = "navigation-testing", version.ref = "composeNavigation" } +test-hilt-android = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +test-hilt-android-kotlin = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } [bundles] androidx = [