From a9dc65d0b60ab329881ff91328ac5a59c0112ab0 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 28 Feb 2022 08:40:39 +0200 Subject: [PATCH 01/23] Add compose UI --- app/build.gradle | 15 +++ app/src/main/AndroidManifest.xml | 10 ++ .../showcase/ui/compose/ComposeActivity.kt | 22 ++++ .../ui/compose/screen/AppNavigation.kt | 33 ++++++ .../ui/compose/screen/auth/AuthScreen.kt | 101 ++++++++++++++++++ .../ui/compose/screen/auth/AuthScreenState.kt | 80 ++++++++++++++ .../ui/compose/screen/home/HomeScreen.kt | 39 +++++++ .../ui/compose/screen/home/HomeScreenState.kt | 41 +++++++ .../ui/compose/screen/splash/SplashScreen.kt | 31 ++++++ .../fnives/test/showcase/ui/shared/Event.kt | 3 +- gradlescripts/versions.gradle | 4 + 11 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt diff --git a/app/build.gradle b/app/build.gradle index 9ea2b8c..5e58fcd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,6 +34,10 @@ android { buildFeatures { viewBinding true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = project.androidx_compose } sourceSets { @@ -73,7 +77,18 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version" implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" + implementation "androidx.activity:activity-compose:$activity_ktx_version" + implementation "androidx.navigation:navigation-compose:$androidx_navigation" + + implementation "androidx.compose.ui:ui:$androidx_compose" + implementation "androidx.compose.ui:ui-tooling:$androidx_compose" + implementation "androidx.compose.foundation:foundation:$androidx_compose" + implementation "androidx.compose.material:material:$androidx_compose" + implementation "com.google.accompanist:accompanist-insets:$google_accompanist" + implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist" + implementation "io.insert-koin:koin-android:$koin_version" + implementation "io.insert-koin:koin-androidx-compose:$koin_version" implementation "androidx.room:room-runtime:$androidx_room_version" kapt "androidx.room:room-compiler:$androidx_room_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 92f5a5e..f90a9ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt new file mode 100644 index 0000000..bcecc68 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.ui.compose + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import com.google.accompanist.insets.ProvideWindowInsets +import org.fnives.test.showcase.ui.compose.screen.AppNavigation + +class ComposeActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ProvideWindowInsets { + MaterialTheme { + AppNavigation() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt new file mode 100644 index 0000000..4ab1c20 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.ui.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.delay +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreen +import org.fnives.test.showcase.ui.compose.screen.splash.SplashScreen +import org.koin.androidx.compose.get + +@Composable +fun AppNavigation() { + val navController = rememberNavController() + + val isUserLogeInUseCase = get() + LaunchedEffect(isUserLogeInUseCase) { + delay(500) + navController.navigate(if (isUserLogeInUseCase.invoke()) "Home" else "Auth") + } + + NavHost(navController, startDestination = "Splash", modifier = Modifier.background(MaterialTheme.colors.surface)) { + composable("Splash") { SplashScreen() } + composable("Auth") { AuthScreen() } + composable("Home") { Text("Home") } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..4b4f740 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt @@ -0,0 +1,101 @@ +package org.fnives.test.showcase.ui.compose.screen.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.google.accompanist.insets.systemBarsPadding +import org.fnives.test.showcase.R + +@Composable +fun AuthScreen( + authScreenState: AuthScreenState = rememberAuthScreen() +) { + Column( + Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + Title() + Column( + Modifier + .fillMaxWidth() + .padding(16.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextField( + value = authScreenState.username, + onValueChange = { authScreenState.onUsernameChanged(it) }, + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = authScreenState.password, + onValueChange = { authScreenState.onPasswordChanged(it) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) + } + + Snackbar(authScreenState) + LoginButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp), + onClick = { authScreenState.onLogin() } + ) + } +} + +@Composable +private fun Snackbar(authScreenState: AuthScreenState) { + val snackbarState = remember { SnackbarHostState() } + val errorType = authScreenState.error?.consume() + LaunchedEffect(errorType) { + if (errorType != null) { + snackbarState.showSnackbar(errorType.name) + } + } + SnackbarHost(hostState = snackbarState) { + val stringId = errorType?.stringResId() + if (stringId != null) { + Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(stringId)) + } + } + } +} + +@Composable +private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Box(modifier) { + Button(onClick = onClick, Modifier.fillMaxWidth()) { + Text(text = "Login") + } + } +} + +@Composable +private fun Title() { + Text( + stringResource(id = R.string.login_title), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} + +private fun AuthScreenState.ErrorType.stringResId() = when (this) { + AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt new file mode 100644 index 0000000..a7fa90b --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt @@ -0,0 +1,80 @@ +package org.fnives.test.showcase.ui.compose.screen.auth + +import androidx.compose.runtime.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.ui.shared.Event +import org.koin.androidx.compose.get + +@Composable +fun rememberAuthScreen( + stateScope: CoroutineScope = rememberCoroutineScope(), + loginUseCase: LoginUseCase = get(), +): AuthScreenState { + return remember { AuthScreenState(stateScope, loginUseCase) } +} + +class AuthScreenState( + private val stateScope: CoroutineScope, + private val loginUseCase: LoginUseCase, +) { + + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf?>(null) + private set + var navigateToHome by mutableStateOf?>(null) + private set + + fun onUsernameChanged(username: String) { + this.username = username + } + + fun onPasswordChanged(password: String) { + this.password = password + } + + fun onLogin() { + if (loading) { + return + } + loading = true + stateScope.launch { + val credentials = LoginCredentials( + username = username, + password = password + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> error = Event(ErrorType.GENERAL_NETWORK_ERROR) + is Answer.Success -> processLoginStatus(response.data) + } + loading = false + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> navigateToHome = Event(Unit) + LoginStatus.INVALID_CREDENTIALS -> error = Event(ErrorType.INVALID_CREDENTIALS) + LoginStatus.INVALID_USERNAME -> error = Event(ErrorType.UNSUPPORTED_USERNAME).also { println("asdasdasd: ${it.hashCode()}") + } + LoginStatus.INVALID_PASSWORD -> error = Event(ErrorType.UNSUPPORTED_PASSWORD) + } + println("asdasdasd: ${error.hashCode()}") + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt new file mode 100644 index 0000000..b0cd4fa --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.ui.compose.screen.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import org.fnives.test.showcase.R + +@Composable +fun HomeScreen( + homeScreenState = rememberHomeScreenState() +) { + Column(Modifier.fillMaxSize()) { + Title() + SwipeRefresh(state = rememberSwipeRefreshState(isRefreshing = false), onRefresh = { }) { + LazyColumn { + + } + } + } +} + + +@Composable +private fun Title() { + Text( + stringResource(id = R.string.login_title), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt new file mode 100644 index 0000000..fe59ac1 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.ui.compose.screen.home + +import androidx.compose.runtime.* +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.core.login.LogoutUseCase +import org.koin.androidx.compose.get + +@Composable +fun rememberHomeScreenState( + getAllContentUseCase: GetAllContentUseCase = get(), + logoutUseCase: LogoutUseCase = get(), + fetchContentUseCase: FetchContentUseCase = get(), + addContentToFavouriteUseCase: AddContentToFavouriteUseCase = get(), + removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase = get(), +): HomeScreenState { + return remember { + HomeScreenState( + getAllContentUseCase, + logoutUseCase, + fetchContentUseCase, + addContentToFavouriteUseCase, + removeContentFromFavouritesUseCase + ) + } +} + +class HomeScreenState( + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase +) { + + var loading by mutableStateOf(false) + private set + +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt new file mode 100644 index 0000000..a557a66 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.ui.compose.screen.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.fnives.test.showcase.R + + +@Composable +fun SplashScreen() { + Box(Modifier.fillMaxSize().background(colorResource(R.color.purple_700)), contentAlignment = Alignment.Center) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt index 30a323c..dde6082 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt @@ -1,7 +1,6 @@ package org.fnives.test.showcase.ui.shared -@Suppress("DataClassContainsFunctions") -data class Event(private val data: T) { +class Event(private val data: T) { private var consumed: Boolean = false diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index d9fc0f0..e1d8d3e 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -7,6 +7,10 @@ project.ext { androidx_swiperefreshlayout_version = "1.1.0" androidx_room_version = "2.4.1" activity_ktx_version = "1.4.0" + androidx_navigation = "2.4.0" + + androidx_compose = "1.1.0-rc03" + google_accompanist = "0.20.3" coroutines_version = "1.6.0" turbine_version = "0.7.0" From b6e4d282b718538e33c35adf9affcafd14669239 Mon Sep 17 00:00:00 2001 From: Alexandru Gabor Date: Mon, 28 Feb 2022 16:57:29 +0200 Subject: [PATCH 02/23] Add home screen --- app/build.gradle | 1 + .../ui/compose/screen/AppNavigation.kt | 24 +++++- .../ui/compose/screen/auth/AuthScreen.kt | 2 +- .../ui/compose/screen/auth/AuthScreenState.kt | 5 +- .../ui/compose/screen/home/HomeScreen.kt | 79 +++++++++++++++--- .../ui/compose/screen/home/HomeScreenState.kt | 81 ++++++++++++++++++- gradlescripts/versions.gradle | 2 +- 7 files changed, 173 insertions(+), 21 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5e58fcd..a33ff6c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,6 +95,7 @@ dependencies { implementation "androidx.room:room-ktx:$androidx_room_version" implementation "io.coil-kt:coil:$coil_version" + implementation "io.coil-kt:coil-compose:$coil_version" implementation project(":core") diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt index 4ab1c20..f0a71b8 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt @@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.compose.screen import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -12,6 +11,9 @@ import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.delay import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreen +import org.fnives.test.showcase.ui.compose.screen.auth.rememberAuthScreenState +import org.fnives.test.showcase.ui.compose.screen.home.HomeScreen +import org.fnives.test.showcase.ui.compose.screen.home.rememberHomeScreenState import org.fnives.test.showcase.ui.compose.screen.splash.SplashScreen import org.koin.androidx.compose.get @@ -25,9 +27,23 @@ fun AppNavigation() { navController.navigate(if (isUserLogeInUseCase.invoke()) "Home" else "Auth") } - NavHost(navController, startDestination = "Splash", modifier = Modifier.background(MaterialTheme.colors.surface)) { + NavHost( + navController, + startDestination = "Splash", + modifier = Modifier.background(MaterialTheme.colors.surface) + ) { composable("Splash") { SplashScreen() } - composable("Auth") { AuthScreen() } - composable("Home") { Text("Home") } + composable("Auth") { + val authState = rememberAuthScreenState() + AuthScreen(authState) + if (authState.navigateToHome?.consume() != null) { + navController.navigate("Home") + } + } + composable("Home") { + HomeScreen(rememberHomeScreenState { + navController.navigate("Auth") + }) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt index 4b4f740..c53a2ea 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt @@ -15,7 +15,7 @@ import org.fnives.test.showcase.R @Composable fun AuthScreen( - authScreenState: AuthScreenState = rememberAuthScreen() + authScreenState: AuthScreenState = rememberAuthScreenState() ) { Column( Modifier diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt index a7fa90b..c461da3 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt @@ -11,7 +11,7 @@ import org.fnives.test.showcase.ui.shared.Event import org.koin.androidx.compose.get @Composable -fun rememberAuthScreen( +fun rememberAuthScreenState( stateScope: CoroutineScope = rememberCoroutineScope(), loginUseCase: LoginUseCase = get(), ): AuthScreenState { @@ -64,8 +64,7 @@ class AuthScreenState( when (loginStatus) { LoginStatus.SUCCESS -> navigateToHome = Event(Unit) LoginStatus.INVALID_CREDENTIALS -> error = Event(ErrorType.INVALID_CREDENTIALS) - LoginStatus.INVALID_USERNAME -> error = Event(ErrorType.UNSUPPORTED_USERNAME).also { println("asdasdasd: ${it.hashCode()}") - } + LoginStatus.INVALID_USERNAME -> error = Event(ErrorType.UNSUPPORTED_USERNAME) LoginStatus.INVALID_PASSWORD -> error = Event(ErrorType.UNSUPPORTED_PASSWORD) } println("asdasdasd: ${error.hashCode()}") diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt index b0cd4fa..cdbda71 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt @@ -1,39 +1,100 @@ package org.fnives.test.showcase.ui.compose.screen.home -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import coil.compose.rememberImagePainter import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import org.fnives.test.showcase.R +import org.fnives.test.showcase.model.content.FavouriteContent @Composable fun HomeScreen( - homeScreenState = rememberHomeScreenState() + homeScreenState: HomeScreenState = rememberHomeScreenState() ) { Column(Modifier.fillMaxSize()) { - Title() - SwipeRefresh(state = rememberSwipeRefreshState(isRefreshing = false), onRefresh = { }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Title(Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.logout_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), + modifier = Modifier + .padding(16.dp) + .clickable { homeScreenState.onLogout() } + ) + } + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), + onRefresh = { + homeScreenState.onRefresh() + }) { LazyColumn { - + items(homeScreenState.content) { item -> + Item( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + favouriteContent = item, + onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) } + ) + } } } } } +@Composable +private fun Item( + modifier: Modifier = Modifier, + favouriteContent: FavouriteContent, + onFavouriteToggle: () -> Unit, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = rememberImagePainter(favouriteContent.content.imageUrl.url), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .height(120.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + ) + Column( + Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text(text = favouriteContent.content.title) + Text(text = favouriteContent.content.description) + } + Image( + painter = painterResource(id = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24), + contentDescription = null, + Modifier.clickable { onFavouriteToggle() } + ) + } +} + @Composable -private fun Title() { +private fun Title(modifier: Modifier = Modifier) { Text( stringResource(id = R.string.login_title), - modifier = Modifier.padding(16.dp), + modifier = modifier.padding(16.dp), style = MaterialTheme.typography.h4 ) } diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt index fe59ac1..580af5b 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt @@ -1,41 +1,116 @@ package org.fnives.test.showcase.ui.compose.screen.home -import androidx.compose.runtime.* +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase import org.fnives.test.showcase.core.content.FetchContentUseCase import org.fnives.test.showcase.core.content.GetAllContentUseCase import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase import org.fnives.test.showcase.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource import org.koin.androidx.compose.get @Composable fun rememberHomeScreenState( + stateScope: CoroutineScope = rememberCoroutineScope(), getAllContentUseCase: GetAllContentUseCase = get(), logoutUseCase: LogoutUseCase = get(), fetchContentUseCase: FetchContentUseCase = get(), addContentToFavouriteUseCase: AddContentToFavouriteUseCase = get(), removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase = get(), + onLogout: () -> Unit = {}, ): HomeScreenState { return remember { HomeScreenState( + stateScope, getAllContentUseCase, logoutUseCase, fetchContentUseCase, addContentToFavouriteUseCase, - removeContentFromFavouritesUseCase + removeContentFromFavouritesUseCase, + onLogout, ) } } class HomeScreenState( + private val stateScope: CoroutineScope, private val getAllContentUseCase: GetAllContentUseCase, private val logoutUseCase: LogoutUseCase, private val fetchContentUseCase: FetchContentUseCase, private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, - private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase, + private val logoutEvent: () -> Unit, ) { var loading by mutableStateOf(false) private set + var isError by mutableStateOf(false) + private set + var content by mutableStateOf>(emptyList()) + private set + init { + stateScope.launch { + fetch().collect { + content = it + } + } + } + + private fun fetch() = getAllContentUseCase.get() + .mapNotNull { + when (it) { + is Resource.Error -> { + isError = true + loading = false + return@mapNotNull emptyList() + } + is Resource.Loading -> { + isError = false + loading = true + return@mapNotNull null + } + is Resource.Success -> { + isError = false + loading = false + return@mapNotNull it.data + } + } + } + + fun onLogout() { + stateScope.launch { + logoutUseCase.invoke() + logoutEvent() + } + } + + fun onRefresh() { + if (loading) return + loading = true + stateScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + stateScope.launch { + val item = content.firstOrNull { it.content.id == contentId } ?: return@launch + if (item.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } } \ No newline at end of file diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index e1d8d3e..c22bee4 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -15,7 +15,7 @@ project.ext { coroutines_version = "1.6.0" turbine_version = "0.7.0" koin_version = "3.1.2" - coil_version = "1.1.1" + coil_version = "1.4.0" retrofit_version = "2.9.0" okhttp_version = "4.9.1" moshi_version = "1.13.0" From 0ca6ac9c9a40ae44009e1767e699e9b0b20f2998 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 1 Mar 2022 12:49:10 +0200 Subject: [PATCH 03/23] Improve auth screen --- app/build.gradle | 1 + .../ui/compose/screen/auth/AuthScreen.kt | 112 ++++++++++++------ .../ui/compose/screen/auth/AuthScreenState.kt | 14 ++- 3 files changed, 83 insertions(+), 44 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a33ff6c..3fa3f9c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" implementation "com.google.android.material:material:$androidx_material_version" implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version" + implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0" implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version" diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt index c53a2ea..2bfe613 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt @@ -1,72 +1,106 @@ package org.fnives.test.showcase.ui.compose.screen.auth import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.systemBarsPadding +import androidx.constraintlayout.compose.ConstraintLayout +import com.google.accompanist.insets.statusBarsPadding import org.fnives.test.showcase.R @Composable fun AuthScreen( authScreenState: AuthScreenState = rememberAuthScreenState() ) { - Column( - Modifier - .fillMaxSize() - .systemBarsPadding() - ) { - Title() - Column( + ConstraintLayout(Modifier.fillMaxSize()) { + val (title, credentials, snackbar, loading, login) = createRefs() + Title( Modifier - .fillMaxWidth() - .padding(16.dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextField( - value = authScreenState.username, - onValueChange = { authScreenState.onUsernameChanged(it) }, - modifier = Modifier.fillMaxWidth() - ) - TextField( - value = authScreenState.password, - onValueChange = { authScreenState.onPasswordChanged(it) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - ) + .statusBarsPadding() + .constrainAs(title) { top.linkTo(parent.top) }) + CredentialsFields(authScreenState, Modifier.constrainAs(credentials) { + top.linkTo(title.bottom) + bottom.linkTo(login.top) + }) + Snackbar(authScreenState, Modifier.constrainAs(snackbar) { + bottom.linkTo(login.top) + }) + if (authScreenState.loading) { + CircularProgressIndicator(Modifier.constrainAs(loading) { + bottom.linkTo(login.top) + centerHorizontallyTo(parent) + }) } - - Snackbar(authScreenState) LoginButton( modifier = Modifier - .align(Alignment.CenterHorizontally) + .constrainAs(login) { bottom.linkTo(parent.bottom) } .padding(16.dp), onClick = { authScreenState.onLogin() } ) } } +@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun Snackbar(authScreenState: AuthScreenState) { +private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { + val keyboardController = LocalSoftwareKeyboardController.current + Column( + modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = authScreenState.username, + label = { Text(text = stringResource(id = R.string.username)) }, + placeholder = { Text(text = stringResource(id = R.string.username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + onValueChange = { authScreenState.onUsernameChanged(it) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = authScreenState.password, + label = { Text(text = stringResource(id = R.string.password)) }, + placeholder = { Text(text = stringResource(id = R.string.password)) }, + onValueChange = { authScreenState.onPasswordChanged(it) }, + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + authScreenState.onLogin() + }), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) + } +} + +@Composable +private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { val snackbarState = remember { SnackbarHostState() } - val errorType = authScreenState.error?.consume() - LaunchedEffect(errorType) { - if (errorType != null) { - snackbarState.showSnackbar(errorType.name) + val error = authScreenState.error + LaunchedEffect(error) { + if (error != null) { + snackbarState.showSnackbar(error.name) + authScreenState.dismissError() } } - SnackbarHost(hostState = snackbarState) { - val stringId = errorType?.stringResId() + SnackbarHost(hostState = snackbarState, modifier) { + val stringId = error?.stringResId() if (stringId != null) { Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) { Text(text = stringResource(stringId)) @@ -85,10 +119,10 @@ private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { } @Composable -private fun Title() { +private fun Title(modifier: Modifier = Modifier) { Text( stringResource(id = R.string.login_title), - modifier = Modifier.padding(16.dp), + modifier = modifier.padding(16.dp), style = MaterialTheme.typography.h4 ) } diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt index c461da3..360def2 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt @@ -29,7 +29,7 @@ class AuthScreenState( private set var loading by mutableStateOf(false) private set - var error by mutableStateOf?>(null) + var error by mutableStateOf(null) private set var navigateToHome by mutableStateOf?>(null) private set @@ -53,7 +53,7 @@ class AuthScreenState( password = password ) when (val response = loginUseCase.invoke(credentials)) { - is Answer.Error -> error = Event(ErrorType.GENERAL_NETWORK_ERROR) + is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR is Answer.Success -> processLoginStatus(response.data) } loading = false @@ -63,13 +63,17 @@ class AuthScreenState( private fun processLoginStatus(loginStatus: LoginStatus) { when (loginStatus) { LoginStatus.SUCCESS -> navigateToHome = Event(Unit) - LoginStatus.INVALID_CREDENTIALS -> error = Event(ErrorType.INVALID_CREDENTIALS) - LoginStatus.INVALID_USERNAME -> error = Event(ErrorType.UNSUPPORTED_USERNAME) - LoginStatus.INVALID_PASSWORD -> error = Event(ErrorType.UNSUPPORTED_PASSWORD) + LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS + LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME + LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD } println("asdasdasd: ${error.hashCode()}") } + fun dismissError() { + error = null + } + enum class ErrorType { INVALID_CREDENTIALS, GENERAL_NETWORK_ERROR, From d74534d96bbe6cd5b1d24df70b7065ab9d01e1b3 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 1 Mar 2022 13:20:25 +0200 Subject: [PATCH 04/23] Show error on home screen --- .../ui/compose/screen/home/HomeScreen.kt | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt index cdbda71..9c23830 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import coil.compose.rememberImagePainter import com.google.accompanist.swiperefresh.SwipeRefresh @@ -39,18 +40,23 @@ fun HomeScreen( .clickable { homeScreenState.onLogout() } ) } - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), - onRefresh = { - homeScreenState.onRefresh() - }) { - LazyColumn { - items(homeScreenState.content) { item -> - Item( - Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - favouriteContent = item, - onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) } - ) + Box { + if (homeScreenState.isError) { + ErrorText(Modifier.align(Alignment.Center)) + } + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), + onRefresh = { + homeScreenState.onRefresh() + }) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(homeScreenState.content) { item -> + Item( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + favouriteContent = item, + onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) } + ) + } } } } @@ -98,3 +104,13 @@ private fun Title(modifier: Modifier = Modifier) { style = MaterialTheme.typography.h4 ) } + +@Composable +private fun ErrorText(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.something_went_wrong), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center + ) +} \ No newline at end of file From 4feb92d4ede919fc03f3697e392beccadc0809b9 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 1 Mar 2022 16:18:02 +0200 Subject: [PATCH 05/23] Add first compose test --- app/build.gradle | 3 + .../ui/AuthComposeInstrumentedTest.kt | 67 +++++++++++++++++++ .../test/showcase/ui/ComposeLoginRobot.kt | 33 +++++++++ .../showcase/ui/compose/ComposeActivity.kt | 16 +++-- .../ui/compose/screen/auth/AuthScreen.kt | 14 +++- 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt diff --git a/app/build.gradle b/app/build.gradle index 3fa3f9c..cb29971 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -132,6 +132,9 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" androidTestImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" + testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" +// debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose" androidTestImplementation project(':mockserver') androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt new file mode 100644 index 0000000..82db8f8 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -0,0 +1,67 @@ +package org.fnives.test.showcase.ui + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule +import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.ui.compose.ComposeActivity +import org.fnives.test.showcase.ui.compose.TestShowCaseApp +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.koin.test.KoinTest + +@RunWith(AndroidJUnit4::class) +class AuthComposeInstrumentedTest : KoinTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + +// private lateinit var activityScenario: ActivityScenario + + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() + private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private val mainDispatcherTestRule = MainDispatcherTestRule() + private lateinit var robot: ComposeLoginRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) + .around(mainDispatcherTestRule) + + @Before + fun setup() { + robot = ComposeLoginRobot(composeTestRule) + composeTestRule.setContent { + TestShowCaseApp() + } + } + +// @After +// fun tearDown() { +// activityScenario.safeClose() +// } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") + ) + composeTestRule.waitForIdle() + robot + .setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() +// .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() +// robot.assertNavigatedToHome() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt new file mode 100644 index 0000000..95e9b4a --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.ui + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreenTag + +class ComposeLoginRobot( + private val composeTestRule: ComposeTestRule, +) { + + fun setUsername(username: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + } + + fun setPassword(password: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + } + + fun assertPassword(password: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).assertTextEquals(password) + } + + fun assertUsername(username: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextEquals(username) + } + + fun clickOnLogin(): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoginButton).performClick() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt index bcecc68..f8b60d1 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable import com.google.accompanist.insets.ProvideWindowInsets import org.fnives.test.showcase.ui.compose.screen.AppNavigation @@ -12,11 +13,16 @@ class ComposeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - ProvideWindowInsets { - MaterialTheme { - AppNavigation() - } - } + TestShowCaseApp() + } + } +} + +@Composable +fun TestShowCaseApp() { + ProvideWindowInsets { + MaterialTheme { + AppNavigation() } } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt index 2bfe613..081d080 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -69,7 +70,7 @@ private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifi placeholder = { Text(text = stringResource(id = R.string.username)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), onValueChange = { authScreenState.onUsernameChanged(it) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().testTag(AuthScreenTag.UsernameInput) ) OutlinedTextField( value = authScreenState.password, @@ -85,6 +86,7 @@ private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifi modifier = Modifier .fillMaxWidth() .padding(top = 16.dp) + .testTag(AuthScreenTag.PasswordInput) ) } } @@ -112,7 +114,7 @@ private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modi @Composable private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { Box(modifier) { - Button(onClick = onClick, Modifier.fillMaxWidth()) { + Button(onClick = onClick, Modifier.fillMaxWidth().testTag(AuthScreenTag.LoginButton)) { Text(text = "Login") } } @@ -132,4 +134,10 @@ private fun AuthScreenState.ErrorType.stringResId() = when (this) { AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid -} \ No newline at end of file +} + +object AuthScreenTag { + const val UsernameInput = "AuthScreenTag.UsernameInput" + const val PasswordInput = "AuthScreenTag.PasswordInput" + const val LoginButton = "AuthScreenTag.LoginButton" +} From 6ddd057e66de446a79d7c1ef7d88d4b7ddcb26a1 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 1 Mar 2022 17:35:43 +0200 Subject: [PATCH 06/23] Wait for idling resources --- .../test/showcase/ui/AuthComposeInstrumentedTest.kt | 12 ++++++------ .../org/fnives/test/showcase/ui/ComposeLoginRobot.kt | 9 +++------ .../testutils/idling/MainDispatcherTestRule.kt | 4 ++-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 82db8f8..ca38f2c 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -5,8 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.testutils.idling.anyResourceIdling import org.fnives.test.showcase.ui.compose.ComposeActivity -import org.fnives.test.showcase.ui.compose.TestShowCaseApp import org.junit.Before import org.junit.Rule import org.junit.Test @@ -32,12 +32,10 @@ class AuthComposeInstrumentedTest : KoinTest { val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) .around(mainDispatcherTestRule) + @Before fun setup() { robot = ComposeLoginRobot(composeTestRule) - composeTestRule.setContent { - TestShowCaseApp() - } } // @After @@ -51,16 +49,18 @@ class AuthComposeInstrumentedTest : KoinTest { mockServerScenarioSetup.setScenario( AuthScenario.Success(password = "alma", username = "banan") ) + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } composeTestRule.waitForIdle() robot .setPassword("alma") .setUsername("banan") - .assertPassword("alma") .assertUsername("banan") + .assertPassword("alma") .clickOnLogin() // .assertLoadingBeforeRequests() - mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() +// mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() // robot.assertNavigatedToHome() } diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 95e9b4a..335daf9 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,10 +1,7 @@ package org.fnives.test.showcase.ui -import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( @@ -20,11 +17,11 @@ class ComposeLoginRobot( } fun assertPassword(password: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).assertTextEquals(password) + composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) } fun assertUsername(username: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextEquals(username) + composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) } fun clickOnLogin(): ComposeLoginRobot = apply { diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt index 6553624..07d3b74 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt @@ -22,13 +22,13 @@ class MainDispatcherTestRule : TestRule { @Throws(Throwable::class) override fun evaluate() { val dispatcher = StandardTestDispatcher() - Dispatchers.setMain(dispatcher) +// Dispatchers.setMain(dispatcher) testDispatcher = dispatcher DatabaseInitialization.dispatcher = dispatcher try { base.evaluate() } finally { - Dispatchers.resetMain() +// Dispatchers.resetMain() } } } From e4ac3f78b671721ed75b9c4314481fe9375cb6ea Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 7 Mar 2022 08:58:18 +0200 Subject: [PATCH 07/23] Move compose package up --- .../test/showcase/ui/AuthComposeInstrumentedTest.kt | 2 +- .../org/fnives/test/showcase/ui/ComposeLoginRobot.kt | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../showcase/{ui => }/compose/ComposeActivity.kt | 4 ++-- .../{ui => }/compose/screen/AppNavigation.kt | 12 ++++++------ .../{ui => }/compose/screen/auth/AuthScreen.kt | 2 +- .../{ui => }/compose/screen/auth/AuthScreenState.kt | 2 +- .../{ui => }/compose/screen/home/HomeScreen.kt | 2 +- .../{ui => }/compose/screen/home/HomeScreenState.kt | 2 +- .../{ui => }/compose/screen/splash/SplashScreen.kt | 7 +------ 10 files changed, 16 insertions(+), 21 deletions(-) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/ComposeActivity.kt (85%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/AppNavigation.kt (77%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/auth/AuthScreen.kt (98%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/auth/AuthScreenState.kt (97%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/home/HomeScreen.kt (98%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/home/HomeScreenState.kt (98%) rename app/src/main/java/org/fnives/test/showcase/{ui => }/compose/screen/splash/SplashScreen.kt (71%) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index ca38f2c..92d67e7 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -6,7 +6,7 @@ import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule import org.fnives.test.showcase.testutils.idling.anyResourceIdling -import org.fnives.test.showcase.ui.compose.ComposeActivity +import org.fnives.test.showcase.compose.ComposeActivity import org.junit.Before import org.junit.Rule import org.junit.Test diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 335daf9..bfbf5a8 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -2,7 +2,7 @@ package org.fnives.test.showcase.ui import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.ComposeTestRule -import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreenTag +import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( private val composeTestRule: ComposeTestRule, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f90a9ed..0e1fd4b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt b/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt similarity index 85% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt rename to app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt index f8b60d1..5a0b0cd 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/ComposeActivity.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose +package org.fnives.test.showcase.compose import android.os.Bundle import androidx.activity.compose.setContent @@ -6,7 +6,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import com.google.accompanist.insets.ProvideWindowInsets -import org.fnives.test.showcase.ui.compose.screen.AppNavigation +import org.fnives.test.showcase.compose.screen.AppNavigation class ComposeActivity : AppCompatActivity() { diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt similarity index 77% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt index f0a71b8..27890b3 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose.screen +package org.fnives.test.showcase.compose.screen import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme @@ -10,11 +10,11 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.delay import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase -import org.fnives.test.showcase.ui.compose.screen.auth.AuthScreen -import org.fnives.test.showcase.ui.compose.screen.auth.rememberAuthScreenState -import org.fnives.test.showcase.ui.compose.screen.home.HomeScreen -import org.fnives.test.showcase.ui.compose.screen.home.rememberHomeScreenState -import org.fnives.test.showcase.ui.compose.screen.splash.SplashScreen +import org.fnives.test.showcase.compose.screen.auth.AuthScreen +import org.fnives.test.showcase.compose.screen.auth.rememberAuthScreenState +import org.fnives.test.showcase.compose.screen.home.HomeScreen +import org.fnives.test.showcase.compose.screen.home.rememberHomeScreenState +import org.fnives.test.showcase.compose.screen.splash.SplashScreen import org.koin.androidx.compose.get @Composable diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt similarity index 98% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 081d080..3cfeaae 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose.screen.auth +package org.fnives.test.showcase.compose.screen.auth import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt similarity index 97% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt index 360def2..843f62f 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose.screen.auth +package org.fnives.test.showcase.compose.screen.auth import androidx.compose.runtime.* import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt similarity index 98% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt index 9c23830..c397037 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose.screen.home +package org.fnives.test.showcase.compose.screen.home import androidx.compose.foundation.Image import androidx.compose.foundation.clickable diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt similarity index 98% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt index 580af5b..38a283b 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/home/HomeScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.ui.compose.screen.home +package org.fnives.test.showcase.compose.screen.home import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt similarity index 71% rename from app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt rename to app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt index a557a66..aeff842 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/compose/screen/splash/SplashScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt @@ -1,21 +1,16 @@ -package org.fnives.test.showcase.ui.compose.screen.splash +package org.fnives.test.showcase.compose.screen.splash import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import org.fnives.test.showcase.R From b003e233056e04697a55b4a759d136bcd1654fe7 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 7 Mar 2022 09:24:16 +0200 Subject: [PATCH 08/23] Add password visibility toggle --- app/build.gradle | 1 + .../test/showcase/ui/ComposeLoginRobot.kt | 1 + .../compose/screen/auth/AuthScreen.kt | 88 +++++++++++++------ .../main/res/drawable/avd_hide_password.xml | 88 +++++++++++++++++++ .../main/res/drawable/avd_show_password.xml | 87 ++++++++++++++++++ 5 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 app/src/main/res/drawable/avd_hide_password.xml create mode 100644 app/src/main/res/drawable/avd_show_password.xml diff --git a/app/build.gradle b/app/build.gradle index cb29971..4655d31 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,6 +85,7 @@ dependencies { implementation "androidx.compose.ui:ui-tooling:$androidx_compose" implementation "androidx.compose.foundation:foundation:$androidx_compose" implementation "androidx.compose.material:material:$androidx_compose" + implementation "androidx.compose.animation:animation-graphics:$androidx_compose" implementation "com.google.accompanist:accompanist-insets:$google_accompanist" implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist" diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index bfbf5a8..bb3db47 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -17,6 +17,7 @@ class ComposeLoginRobot( } fun assertPassword(password: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) } diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 3cfeaae..8fc5cbb 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -1,12 +1,19 @@ package org.fnives.test.showcase.compose.screen.auth +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -16,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import com.google.accompanist.insets.statusBarsPadding @@ -53,10 +61,8 @@ fun AuthScreen( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { - val keyboardController = LocalSoftwareKeyboardController.current Column( modifier .fillMaxWidth() @@ -64,33 +70,57 @@ private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifi verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - OutlinedTextField( - value = authScreenState.username, - label = { Text(text = stringResource(id = R.string.username)) }, - placeholder = { Text(text = stringResource(id = R.string.username)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), - onValueChange = { authScreenState.onUsernameChanged(it) }, - modifier = Modifier.fillMaxWidth().testTag(AuthScreenTag.UsernameInput) - ) - OutlinedTextField( - value = authScreenState.password, - label = { Text(text = stringResource(id = R.string.password)) }, - placeholder = { Text(text = stringResource(id = R.string.password)) }, - onValueChange = { authScreenState.onPasswordChanged(it) }, - keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password), - keyboardActions = KeyboardActions(onDone = { - keyboardController?.hide() - authScreenState.onLogin() - }), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .testTag(AuthScreenTag.PasswordInput) - ) + UsernameField(authScreenState) + PasswordField(authScreenState) } } +@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class) +@Composable +private fun PasswordField(authScreenState: AuthScreenState) { + var passwordVisible by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = authScreenState.password, + label = { Text(text = stringResource(id = R.string.password)) }, + placeholder = { Text(text = stringResource(id = R.string.password)) }, + trailingIcon = { + val image = AnimatedImageVector.animatedVectorResource(R.drawable.avd_show_password) + Icon( + painter = rememberAnimatedVectorPainter(image, passwordVisible), + contentDescription = null, + modifier = Modifier.clickable { passwordVisible = !passwordVisible } + .testTag(AuthScreenTag.PasswordVisibilityToggle) + ) + }, + onValueChange = { authScreenState.onPasswordChanged(it) }, + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + authScreenState.onLogin() + }), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .testTag(AuthScreenTag.PasswordInput) + ) +} + +@Composable +private fun UsernameField(authScreenState: AuthScreenState) { + OutlinedTextField( + value = authScreenState.username, + label = { Text(text = stringResource(id = R.string.username)) }, + placeholder = { Text(text = stringResource(id = R.string.username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + onValueChange = { authScreenState.onUsernameChanged(it) }, + modifier = Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.UsernameInput) + ) +} + @Composable private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { val snackbarState = remember { SnackbarHostState() } @@ -114,7 +144,10 @@ private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modi @Composable private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { Box(modifier) { - Button(onClick = onClick, Modifier.fillMaxWidth().testTag(AuthScreenTag.LoginButton)) { + Button(onClick = onClick, + Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.LoginButton)) { Text(text = "Login") } } @@ -140,4 +173,5 @@ object AuthScreenTag { const val UsernameInput = "AuthScreenTag.UsernameInput" const val PasswordInput = "AuthScreenTag.PasswordInput" const val LoginButton = "AuthScreenTag.LoginButton" + const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle" } diff --git a/app/src/main/res/drawable/avd_hide_password.xml b/app/src/main/res/drawable/avd_hide_password.xml new file mode 100644 index 0000000..89f4cdd --- /dev/null +++ b/app/src/main/res/drawable/avd_hide_password.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_show_password.xml b/app/src/main/res/drawable/avd_show_password.xml new file mode 100644 index 0000000..aed3983 --- /dev/null +++ b/app/src/main/res/drawable/avd_show_password.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 225fbed8498d4c8848811f0b4444dad3c1a9e678 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 7 Mar 2022 10:59:15 +0200 Subject: [PATCH 09/23] Assert loading is displayed --- .../ui/AuthComposeInstrumentedTest.kt | 15 ++++------ .../test/showcase/ui/ComposeLoginRobot.kt | 10 +++++-- .../compose/screen/auth/AuthScreen.kt | 28 ++++++++++--------- .../idling/NetworkSynchronizationTestRule.kt | 2 +- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 92d67e7..61210a1 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -2,11 +2,11 @@ package org.fnives.test.showcase.ui import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.compose.ComposeActivity import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule import org.fnives.test.showcase.testutils.idling.anyResourceIdling -import org.fnives.test.showcase.compose.ComposeActivity import org.junit.Before import org.junit.Rule import org.junit.Test @@ -20,8 +20,6 @@ class AuthComposeInstrumentedTest : KoinTest { @get:Rule val composeTestRule = createAndroidComposeRule() -// private lateinit var activityScenario: ActivityScenario - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = MainDispatcherTestRule() @@ -38,11 +36,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot = ComposeLoginRobot(composeTestRule) } -// @After -// fun tearDown() { -// activityScenario.safeClose() -// } - /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ @Test fun properLoginResultsInNavigationToHome() { @@ -57,8 +50,10 @@ class AuthComposeInstrumentedTest : KoinTest { .setUsername("banan") .assertUsername("banan") .assertPassword("alma") - .clickOnLogin() -// .assertLoadingBeforeRequests() + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() // mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() // robot.assertNavigatedToHome() diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index bb3db47..c792769 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -17,8 +17,10 @@ class ComposeLoginRobot( } fun assertPassword(password: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() - composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) + with(composeTestRule) { + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) + } } fun assertUsername(username: String): ComposeLoginRobot = apply { @@ -28,4 +30,8 @@ class ComposeLoginRobot( fun clickOnLogin(): ComposeLoginRobot = apply { composeTestRule.onNodeWithTag(AuthScreenTag.LoginButton).performClick() } + + fun assertLoading(): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 8fc5cbb..8c9e546 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -8,12 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -47,10 +42,13 @@ fun AuthScreen( bottom.linkTo(login.top) }) if (authScreenState.loading) { - CircularProgressIndicator(Modifier.constrainAs(loading) { - bottom.linkTo(login.top) - centerHorizontallyTo(parent) - }) + CircularProgressIndicator( + Modifier + .testTag(AuthScreenTag.LoadingIndicator) + .constrainAs(loading) { + bottom.linkTo(login.top) + centerHorizontallyTo(parent) + }) } LoginButton( modifier = Modifier @@ -89,7 +87,8 @@ private fun PasswordField(authScreenState: AuthScreenState) { Icon( painter = rememberAnimatedVectorPainter(image, passwordVisible), contentDescription = null, - modifier = Modifier.clickable { passwordVisible = !passwordVisible } + modifier = Modifier + .clickable { passwordVisible = !passwordVisible } .testTag(AuthScreenTag.PasswordVisibilityToggle) ) }, @@ -144,10 +143,12 @@ private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modi @Composable private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { Box(modifier) { - Button(onClick = onClick, + Button( + onClick = onClick, Modifier .fillMaxWidth() - .testTag(AuthScreenTag.LoginButton)) { + .testTag(AuthScreenTag.LoginButton) + ) { Text(text = "Login") } } @@ -172,6 +173,7 @@ private fun AuthScreenState.ErrorType.stringResId() = when (this) { object AuthScreenTag { const val UsernameInput = "AuthScreenTag.UsernameInput" const val PasswordInput = "AuthScreenTag.PasswordInput" + const val LoadingIndicator = "AuthScreenTag.LoadingIndicator" const val LoginButton = "AuthScreenTag.LoginButton" const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle" } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt index d2cf705..8294014 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt @@ -30,7 +30,7 @@ class NetworkSynchronizationTestRule : TestRule, KoinTest { @CheckResult private fun registerNetworkingSynchronization(): Disposable { - val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() + val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients()//.filterIndexed { index, okHttpClient -> index == 0 } .associateBy(keySelector = { it.toString() }) .map { (key, client) -> client.asIdlingResource(key) } .map(::IdlingResourceDisposable) From d948d06378706a14a167dd5b99680f8abec64400 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Fri, 1 Apr 2022 14:59:36 +0300 Subject: [PATCH 10/23] Assert navigation --- .../ui/AuthComposeInstrumentedTest.kt | 17 +++-- .../test/showcase/ui/ComposeScreenRobot.kt | 19 ++++++ .../showcase/compose/screen/AppNavigation.kt | 18 +++-- .../compose/screen/auth/AuthScreen.kt | 3 +- .../compose/screen/auth/AuthScreenState.kt | 1 - .../compose/screen/home/HomeScreen.kt | 3 +- ...MockServerScenarioSetupResetingTestRule.kt | 2 +- .../idling/ComposeMainDispatcherTestRule.kt | 50 ++++++++++++++ .../ComposeNetworkSynchronizationTestRule.kt | 66 +++++++++++++++++++ .../idling/MainDispatcherTestRule.kt | 4 +- .../idling/NetworkSynchronizationTestRule.kt | 2 +- 11 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 61210a1..62966fc 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -5,7 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.compose.ComposeActivity import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule -import org.fnives.test.showcase.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.testutils.idling.ComposeMainDispatcherTestRule +import org.fnives.test.showcase.testutils.idling.ComposeNetworkSynchronizationTestRule import org.fnives.test.showcase.testutils.idling.anyResourceIdling import org.junit.Before import org.junit.Rule @@ -20,10 +21,11 @@ class AuthComposeInstrumentedTest : KoinTest { @get:Rule val composeTestRule = createAndroidComposeRule() - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup - private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mainDispatcherTestRule = ComposeMainDispatcherTestRule() private lateinit var robot: ComposeLoginRobot + private lateinit var screenRobot: ComposeScreenRobot @Rule @JvmField @@ -34,6 +36,7 @@ class AuthComposeInstrumentedTest : KoinTest { @Before fun setup() { robot = ComposeLoginRobot(composeTestRule) + screenRobot = ComposeScreenRobot(composeTestRule) } /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ @@ -44,19 +47,21 @@ class AuthComposeInstrumentedTest : KoinTest { ) composeTestRule.mainClock.advanceTimeBy(500L) composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } - composeTestRule.waitForIdle() + screenRobot.assertAuthScreen() robot .setPassword("alma") .setUsername("banan") .assertUsername("banan") .assertPassword("alma") + composeTestRule.mainClock.autoAdvance = false robot.clickOnLogin() composeTestRule.mainClock.advanceTimeByFrame() robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true -// mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() -// robot.assertNavigatedToHome() + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + screenRobot.assertHomeScreen() } } \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt new file mode 100644 index 0000000..a9c9542 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import org.fnives.test.showcase.compose.screen.AppNavigationTag + +class ComposeScreenRobot( + private val composeTestRule: ComposeTestRule, +) { + + fun assertHomeScreen(): ComposeScreenRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertIsDisplayed() + } + + fun assertAuthScreen(): ComposeScreenRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertIsDisplayed() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt index 27890b3..a71b36c 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt @@ -5,16 +5,17 @@ import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.delay -import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.compose.screen.auth.AuthScreen import org.fnives.test.showcase.compose.screen.auth.rememberAuthScreenState import org.fnives.test.showcase.compose.screen.home.HomeScreen import org.fnives.test.showcase.compose.screen.home.rememberHomeScreenState import org.fnives.test.showcase.compose.screen.splash.SplashScreen +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.koin.androidx.compose.get @Composable @@ -35,15 +36,22 @@ fun AppNavigation() { composable("Splash") { SplashScreen() } composable("Auth") { val authState = rememberAuthScreenState() - AuthScreen(authState) + AuthScreen(Modifier.testTag(AppNavigationTag.AuthScreen), authState) if (authState.navigateToHome?.consume() != null) { navController.navigate("Home") } } composable("Home") { - HomeScreen(rememberHomeScreenState { - navController.navigate("Auth") - }) + HomeScreen( + Modifier.testTag(AppNavigationTag.HomeScreen), + homeScreenState = rememberHomeScreenState( + onLogout = { navController.navigate("Auth") }) + ) } } +} + +object AppNavigationTag { + const val AuthScreen = "AppNavigationTag.AuthScreen" + const val HomeScreen = "AppNavigationTag.HomeScreen" } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 8c9e546..21ed15a 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -26,9 +26,10 @@ import org.fnives.test.showcase.R @Composable fun AuthScreen( + modifier: Modifier = Modifier, authScreenState: AuthScreenState = rememberAuthScreenState() ) { - ConstraintLayout(Modifier.fillMaxSize()) { + ConstraintLayout(modifier.fillMaxSize()) { val (title, credentials, snackbar, loading, login) = createRefs() Title( Modifier diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt index 843f62f..83ea9c6 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt @@ -67,7 +67,6 @@ class AuthScreenState( LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD } - println("asdasdasd: ${error.hashCode()}") } fun dismissError() { diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt index c397037..d09f4a6 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt @@ -26,9 +26,10 @@ import org.fnives.test.showcase.model.content.FavouriteContent @Composable fun HomeScreen( + modifier: Modifier = Modifier, homeScreenState: HomeScreenState = rememberHomeScreenState() ) { - Column(Modifier.fillMaxSize()) { + Column(modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically) { Title(Modifier.weight(1f)) Image( diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupResetingTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupResetingTestRule.kt index 421eb28..5998262 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupResetingTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupResetingTestRule.kt @@ -17,7 +17,7 @@ import org.koin.test.KoinTest */ class MockServerScenarioSetupResetingTestRule( private val reloadKoinModulesIfNecessaryTestRule: ReloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule(), - private val networkSynchronizationTestRule: NetworkSynchronizationTestRule = NetworkSynchronizationTestRule() + private val networkSynchronizationTestRule: TestRule = NetworkSynchronizationTestRule() ) : TestRule, KoinTest { lateinit var mockServerScenarioSetup: MockServerScenarioSetup diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt new file mode 100644 index 0000000..6d04ec1 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt @@ -0,0 +1,50 @@ +package org.fnives.test.showcase.testutils.idling + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +@OptIn(ExperimentalCoroutinesApi::class) +class ComposeMainDispatcherTestRule : TestRule { + + private lateinit var testDispatcher: TestDispatcher + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val dispatcher = StandardTestDispatcher() + testDispatcher = dispatcher + DatabaseInitialization.dispatcher = dispatcher + base.evaluate() + } + } + + fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent { + testDispatcher.advanceUntilIdleWithIdlingResources() + } + + fun advanceUntilIdle() = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceUntilIdle() + } + + fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceTimeBy(delayInMillis) + } + + companion object { + fun TestDispatcher.advanceUntilIdleWithIdlingResources() { + scheduler.advanceUntilIdle() // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + scheduler.advanceUntilIdle() // run coroutines after request is finished + } + scheduler.advanceUntilIdle() + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt new file mode 100644 index 0000000..2cfd4d2 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.annotation.CheckResult +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.test.KoinTest + +class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest { + + private var disposable: Disposable? = null + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + disposable = registerNetworkingSynchronization() + try { + base.evaluate() + } finally { + dispose() + } + } + } + } + + fun dispose() = disposable?.dispose() + + @CheckResult + private fun registerNetworkingSynchronization(): Disposable { + val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map { + ComposeIdlingResourceDisposable(composeTestRule, object : IdlingResource { + override val isIdleNow: Boolean + get() { + return it.isIdleNow + } + }) + } + + return CompositeDisposable(idlingResources) + } +} + + +private class ComposeIdlingResourceDisposable( + private val composeTestRule: ComposeTestRule, + private val idlingResource: IdlingResource +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + composeTestRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + if (isDisposed) return + isDisposed = true + composeTestRule.unregisterIdlingResource(idlingResource) + } +} \ No newline at end of file diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt index 07d3b74..6553624 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/MainDispatcherTestRule.kt @@ -22,13 +22,13 @@ class MainDispatcherTestRule : TestRule { @Throws(Throwable::class) override fun evaluate() { val dispatcher = StandardTestDispatcher() -// Dispatchers.setMain(dispatcher) + Dispatchers.setMain(dispatcher) testDispatcher = dispatcher DatabaseInitialization.dispatcher = dispatcher try { base.evaluate() } finally { -// Dispatchers.resetMain() + Dispatchers.resetMain() } } } diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt index 8294014..d2cf705 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronizationTestRule.kt @@ -30,7 +30,7 @@ class NetworkSynchronizationTestRule : TestRule, KoinTest { @CheckResult private fun registerNetworkingSynchronization(): Disposable { - val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients()//.filterIndexed { index, okHttpClient -> index == 0 } + val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() .associateBy(keySelector = { it.toString() }) .map { (key, client) -> client.asIdlingResource(key) } .map(::IdlingResourceDisposable) From d9725e31e6a12fc53867e4dec0dccc240ab20187 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Fri, 1 Apr 2022 16:06:35 +0300 Subject: [PATCH 11/23] Cover all auth tests --- .../ui/AuthComposeInstrumentedTest.kt | 98 +++++++++++++++++-- .../test/showcase/ui/ComposeLoginRobot.kt | 11 +++ ...reenRobot.kt => ComposeNavigationRobot.kt} | 11 +-- .../compose/screen/auth/AuthScreen.kt | 3 +- 4 files changed, 110 insertions(+), 13 deletions(-) rename app/src/androidTest/java/org/fnives/test/showcase/ui/{ComposeScreenRobot.kt => ComposeNavigationRobot.kt} (62%) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 62966fc..b4e21fb 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -2,6 +2,7 @@ package org.fnives.test.showcase.ui import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.R import org.fnives.test.showcase.compose.ComposeActivity import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule @@ -25,7 +26,7 @@ class AuthComposeInstrumentedTest : KoinTest { private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val mainDispatcherTestRule = ComposeMainDispatcherTestRule() private lateinit var robot: ComposeLoginRobot - private lateinit var screenRobot: ComposeScreenRobot + private lateinit var navigationRobot: ComposeNavigationRobot @Rule @JvmField @@ -36,7 +37,7 @@ class AuthComposeInstrumentedTest : KoinTest { @Before fun setup() { robot = ComposeLoginRobot(composeTestRule) - screenRobot = ComposeScreenRobot(composeTestRule) + navigationRobot = ComposeNavigationRobot(composeTestRule) } /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ @@ -47,9 +48,8 @@ class AuthComposeInstrumentedTest : KoinTest { ) composeTestRule.mainClock.advanceTimeBy(500L) composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } - screenRobot.assertAuthScreen() - robot - .setPassword("alma") + navigationRobot.assertAuthScreen() + robot.setPassword("alma") .setUsername("banan") .assertUsername("banan") .assertPassword("alma") @@ -61,7 +61,93 @@ class AuthComposeInstrumentedTest : KoinTest { composeTestRule.mainClock.autoAdvance = true composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } - screenRobot.assertHomeScreen() + navigationRobot.assertHomeScreen() } + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + navigationRobot.assertAuthScreen() + + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + navigationRobot.assertAuthScreen() + + robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(password = "alma", username = "banan") + ) + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } } \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index c792769..53c237d 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,7 +1,9 @@ package org.fnives.test.showcase.ui +import android.content.Context import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( @@ -34,4 +36,13 @@ class ComposeLoginRobot( fun assertLoading(): ComposeLoginRobot = apply { composeTestRule.onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() } + fun assertNotLoading(): ComposeLoginRobot = apply { + composeTestRule.onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + } + + fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoginError) + .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) + } + } \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt similarity index 62% rename from app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt rename to app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt index a9c9542..881875f 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeScreenRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt @@ -1,19 +1,18 @@ package org.fnives.test.showcase.ui -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag import org.fnives.test.showcase.compose.screen.AppNavigationTag -class ComposeScreenRobot( +class ComposeNavigationRobot( private val composeTestRule: ComposeTestRule, ) { - fun assertHomeScreen(): ComposeScreenRobot = apply { - composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertIsDisplayed() + fun assertHomeScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists() } - fun assertAuthScreen(): ComposeScreenRobot = apply { - composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertIsDisplayed() + fun assertAuthScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists() } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 21ed15a..708d115 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -135,7 +135,7 @@ private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modi val stringId = error?.stringResId() if (stringId != null) { Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) { - Text(text = stringResource(stringId)) + Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError)) } } } @@ -176,5 +176,6 @@ object AuthScreenTag { const val PasswordInput = "AuthScreenTag.PasswordInput" const val LoadingIndicator = "AuthScreenTag.LoadingIndicator" const val LoginButton = "AuthScreenTag.LoginButton" + const val LoginError = "AuthScreenTag.LoginError" const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle" } From 586c811e107b3ef7f02d1e8133c5b1b829489350 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Fri, 1 Apr 2022 16:17:25 +0300 Subject: [PATCH 12/23] Isolate AuthTest to navigation scope and inject fake user storage --- .../showcase/ui/AuthComposeInstrumentedTest.kt | 17 ++++++++++++----- .../showcase/compose/screen/AppNavigation.kt | 3 +-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index b4e21fb..1600cb4 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,9 +1,13 @@ package org.fnives.test.showcase.ui import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.compose.ComposeActivity +import org.fnives.test.showcase.compose.screen.AppNavigation +import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.ComposeMainDispatcherTestRule @@ -36,6 +40,9 @@ class AuthComposeInstrumentedTest : KoinTest { @Before fun setup() { + composeTestRule.setContent { + AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage())) + } robot = ComposeLoginRobot(composeTestRule) navigationRobot = ComposeNavigationRobot(composeTestRule) } @@ -46,7 +53,7 @@ class AuthComposeInstrumentedTest : KoinTest { mockServerScenarioSetup.setScenario( AuthScenario.Success(password = "alma", username = "banan") ) - composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() robot.setPassword("alma") @@ -67,7 +74,7 @@ class AuthComposeInstrumentedTest : KoinTest { /** GIVEN empty password and username WHEN signIn THEN error password is shown */ @Test fun emptyPasswordShowsProperErrorMessage() { - composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() @@ -84,7 +91,7 @@ class AuthComposeInstrumentedTest : KoinTest { /** GIVEN password and empty username WHEN signIn THEN error username is shown */ @Test fun emptyUserNameShowsProperErrorMessage() { - composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() @@ -105,7 +112,7 @@ class AuthComposeInstrumentedTest : KoinTest { mockServerScenarioSetup.setScenario( AuthScenario.InvalidCredentials(password = "alma", username = "banan") ) - composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() robot.setUsername("alma") @@ -131,7 +138,7 @@ class AuthComposeInstrumentedTest : KoinTest { mockServerScenarioSetup.setScenario( AuthScenario.GenericError(username = "alma", password = "banan") ) - composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() robot.setUsername("alma") diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt index a71b36c..7b11bef 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt @@ -19,10 +19,9 @@ import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.koin.androidx.compose.get @Composable -fun AppNavigation() { +fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) { val navController = rememberNavController() - val isUserLogeInUseCase = get() LaunchedEffect(isUserLogeInUseCase) { delay(500) navController.navigate(if (isUserLogeInUseCase.invoke()) "Home" else "Auth") From 61b82f1ba7f86acb7b5603ebdae291f2fc8f0cb7 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Sat, 2 Apr 2022 11:37:35 +0300 Subject: [PATCH 13/23] Refactor navigation event --- app/build.gradle | 2 +- .../test/showcase/ui/AuthComposeInstrumentedTest.kt | 4 +--- .../test/showcase/compose/screen/AppNavigation.kt | 9 ++++----- .../showcase/compose/screen/auth/AuthScreenState.kt | 13 +++++++------ gradlescripts/versions.gradle | 4 ++-- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 4655d31..52d50d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -135,7 +135,7 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version" androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" -// debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose" + debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose" androidTestImplementation project(':mockserver') androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 1600cb4..2cedceb 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,10 +1,8 @@ package org.fnives.test.showcase.ui -import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R -import org.fnives.test.showcase.compose.ComposeActivity import org.fnives.test.showcase.compose.screen.AppNavigation import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase @@ -24,7 +22,7 @@ import org.koin.test.KoinTest class AuthComposeInstrumentedTest : KoinTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createComposeRule() private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt index 7b11bef..2c1f7cb 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt @@ -34,11 +34,10 @@ fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) { ) { composable("Splash") { SplashScreen() } composable("Auth") { - val authState = rememberAuthScreenState() - AuthScreen(Modifier.testTag(AppNavigationTag.AuthScreen), authState) - if (authState.navigateToHome?.consume() != null) { - navController.navigate("Home") - } + AuthScreen(modifier = Modifier.testTag(AppNavigationTag.AuthScreen), + authScreenState = rememberAuthScreenState( + onLoginSuccess = { navController.navigate("Home") } + )) } composable("Home") { HomeScreen( diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt index 83ea9c6..92ce536 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt @@ -1,26 +1,29 @@ package org.fnives.test.showcase.compose.screen.auth import androidx.compose.runtime.* +import androidx.compose.ui.platform.AndroidUiDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.fnives.test.showcase.core.login.LoginUseCase import org.fnives.test.showcase.model.auth.LoginCredentials import org.fnives.test.showcase.model.auth.LoginStatus import org.fnives.test.showcase.model.shared.Answer -import org.fnives.test.showcase.ui.shared.Event import org.koin.androidx.compose.get @Composable fun rememberAuthScreenState( - stateScope: CoroutineScope = rememberCoroutineScope(), + stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main }, loginUseCase: LoginUseCase = get(), + onLoginSuccess: () -> Unit = {}, ): AuthScreenState { - return remember { AuthScreenState(stateScope, loginUseCase) } + return remember { AuthScreenState(stateScope, loginUseCase, onLoginSuccess) } } class AuthScreenState( private val stateScope: CoroutineScope, private val loginUseCase: LoginUseCase, + private val onLoginSuccess: () -> Unit = {}, ) { var username by mutableStateOf("") @@ -31,8 +34,6 @@ class AuthScreenState( private set var error by mutableStateOf(null) private set - var navigateToHome by mutableStateOf?>(null) - private set fun onUsernameChanged(username: String) { this.username = username @@ -62,7 +63,7 @@ class AuthScreenState( private fun processLoginStatus(loginStatus: LoginStatus) { when (loginStatus) { - LoginStatus.SUCCESS -> navigateToHome = Event(Unit) + LoginStatus.SUCCESS -> onLoginSuccess() LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index c22bee4..29b40ff 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -9,8 +9,8 @@ project.ext { activity_ktx_version = "1.4.0" androidx_navigation = "2.4.0" - androidx_compose = "1.1.0-rc03" - google_accompanist = "0.20.3" + androidx_compose = "1.1.0" + google_accompanist = "0.23.1" coroutines_version = "1.6.0" turbine_version = "0.7.0" From 95dc76f7f1d9c2963f3cda22c92a38274c3676cd Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 12:29:44 +0300 Subject: [PATCH 14/23] Add instructions for testing compose --- .../ui/AuthComposeInstrumentedTest.kt | 11 +- ...tcherTestRule.kt => DispatcherTestRule.kt} | 2 +- codekata/compose.instructionset.md | 326 ++++++++++++++++++ 3 files changed, 331 insertions(+), 8 deletions(-) rename app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/{ComposeMainDispatcherTestRule.kt => DispatcherTestRule.kt} (97%) create mode 100644 codekata/compose.instructionset.md diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 2cedceb..8fddc98 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -8,8 +8,7 @@ import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule -import org.fnives.test.showcase.testutils.idling.ComposeMainDispatcherTestRule -import org.fnives.test.showcase.testutils.idling.ComposeNetworkSynchronizationTestRule +import org.fnives.test.showcase.testutils.idling.DispatcherTestRule import org.fnives.test.showcase.testutils.idling.anyResourceIdling import org.junit.Before import org.junit.Rule @@ -24,16 +23,16 @@ class AuthComposeInstrumentedTest : KoinTest { @get:Rule val composeTestRule = createComposeRule() - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)) + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup - private val mainDispatcherTestRule = ComposeMainDispatcherTestRule() + private val dispatcherTestRule = DispatcherTestRule() private lateinit var robot: ComposeLoginRobot private lateinit var navigationRobot: ComposeNavigationRobot @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) - .around(mainDispatcherTestRule) + .around(dispatcherTestRule) @Before @@ -72,7 +71,6 @@ class AuthComposeInstrumentedTest : KoinTest { /** GIVEN empty password and username WHEN signIn THEN error password is shown */ @Test fun emptyPasswordShowsProperErrorMessage() { - composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() @@ -89,7 +87,6 @@ class AuthComposeInstrumentedTest : KoinTest { /** GIVEN password and empty username WHEN signIn THEN error username is shown */ @Test fun emptyUserNameShowsProperErrorMessage() { - composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } navigationRobot.assertAuthScreen() diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/DispatcherTestRule.kt similarity index 97% rename from app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt rename to app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/DispatcherTestRule.kt index 6d04ec1..27e675d 100644 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeMainDispatcherTestRule.kt +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/DispatcherTestRule.kt @@ -10,7 +10,7 @@ import org.junit.runner.Description import org.junit.runners.model.Statement @OptIn(ExperimentalCoroutinesApi::class) -class ComposeMainDispatcherTestRule : TestRule { +class DispatcherTestRule : TestRule { private lateinit var testDispatcher: TestDispatcher diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md new file mode 100644 index 0000000..e927e53 --- /dev/null +++ b/codekata/compose.instructionset.md @@ -0,0 +1,326 @@ +## Login UI Test with Compose + +This section is equivalent to the one with Login UI Test from robolectric.instructionset.md. +Make sure to read that one first as this one only focuses on the differences that Compose brings. +We will write the same tests so that we see clearly the differences between the two. + +### Robot Pattern + +We will apply the same Robot Pattern, since the concept applies exactly the same. +The only thing that changes is the implementation details of the Robot class. + +Here is a list of actions we want to do: +- we want to be able to type in the username +- we want to be able to type in the password +- we want to be able the username or password is indeed shows on the UI +- we want to be able to click on signin +- we want to be able verify if we are loading or not +- we want to verify if an error is shown or not +- we want to check if we navigated to Main or not + +##### So here is the code for our the UI interactions + +```kotlin + fun setUsername(username: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + } + + fun setPassword(password: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + } + + fun assertPassword(password: String): ComposeLoginRobot = apply { + with(composeTestRule) { + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) + } + } + + fun assertUsername(username: String): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) + } + + fun clickOnLogin(): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoginButton).performClick() + } + + fun assertLoading(): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + } + fun assertNotLoading(): ComposeLoginRobot = apply { + composeTestRule.onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + } + + fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { + composeTestRule.onNodeWithTag(AuthScreenTag.LoginError) + .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) + } +``` + +While in the View system we're using Espresso to interact with views, +in Compose we need a reference to the `ComposeTestRule` that contains our UI, +which we will pass as a constructor parameter to the robot. + +To create a `ComposeTestRule` you simply need to: + +```kotlin + @get:Rule + val composeTestRule = createComposeRule() +``` + +> Note: You need to add a debug dependency for the rule: `debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")` + +Since we don't have view ids in Compose we need to search composables by tags, using for example `onNodeWithTag` finder. +To add a tag to a composable use the `testTag` modifier in your UI, for example: + +```kotlin + Modifier.testTag(AuthScreenTag.UsernameInput) +``` + +Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. +For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis + +##### Next up, we need to verify if we navigated: + +If the navigation is also in compose we don't have an intent to check if we navigated. +So instead, we're simply searching for regular composables that represent our destinations. + +This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists: + +```kotlin + fun assertHomeScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists() + } + + fun assertAuthScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists() + } +``` + +##### What about the Snackbar + +Since everything in Compose is a composable, our Snackbar doesn't have anything special. +Put a tag on it and use the same finders and assertions. + +#### Test class setup + +The setup is the mostly the same as for View so for the sake of simplicity let's focus on the differences. + + +##### Initializing the UI +We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. +If you need a specific activity, use `createAndroidComposeRule()`. + +```kotlin +@get:Rule +val composeTestRule = createComposeRule() + +@Before +fun setup() { + composeTestRule.setContent { + AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage())) + } + // ... +} +``` + +In `setContent` we can have any composable no matter how "small" or "big", it could be a single button or the whole app. +Here we are setting AppNavigation as the content, since the tests will be integration tests which will check navigation events. + +Notice that we are injecting a fake local storage to control the logged in state. + +##### The Robot +For the robot we will use the compose implementation of it. + +```kotlin +private lateinit var robot: ComposeLoginRobot +private lateinit var navigationRobot: ComposeNavigationRobot + +@Before +fun setup() { + // ... + robot = ComposeLoginRobot(composeTestRule) + navigationRobot = ComposeNavigationRobot(composeTestRule) +} +``` + +##### Networking and Coroutines + +Network synchronization and mocking is the same as for View. + +```kotlin +private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() +private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup +``` + +Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. + +```kotlin +private val dispatcherTestRule = DispatcherTestRule() +``` + +Setting the rules: + +```kotlin +@Rule +@JvmField +val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) + .around(dispatcherTestRule) +``` + +### 1. `properLoginResultsInNavigationToHome` + +With this setup our test should be pretty simple. + +First we mock our request: +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") +) +``` + +Then we wait for the idling resources, more precisely for the app to navigate us correctly to AuthScreen since we're not logged in: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +``` + +> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere. + +We assert that we are indeed on the correct screen +```kotlin +navigationRobot.assertAuthScreen() +``` + +We insert the credentials into the input field: +```kotlin +robot.setPassword("alma") + .setUsername("banan") + .assertUsername("banan") + .assertPassword("alma") +``` + +Now thing are getting a little tricky. We want to click on login and assert that loading is displayed before navigating away. +The problem is that, by the time the robot will look for the loading indicator, the app would have already be at the home screen. +To slow things down we will disable clock autoAdvancing: + +```kotlin +composeTestRule.mainClock.autoAdvance = false // Stop the clock +robot.clickOnLogin() // Click the button +composeTestRule.mainClock.advanceTimeByFrame() // Advance the clock by one frame +robot.assertLoading() // Assert the loading +composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again +``` + +Lastly we check the navigation was correct, meaning we should be on the home screen: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } // wait for login network call +navigationRobot.assertHomeScreen() +``` + +### 2. `emptyPasswordShowsProperErrorMessage` + +Next up we verify what happens if the user doesn't set their password. We don't need a request in this case. + +First we check that we are in the write place: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +navigationRobot.assertAuthScreen() +``` + +Then we set the username: +```kotlin +robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() +``` + +Finally we let coroutines go and verify the error is shown and we have not navigated: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotLoading() +navigationRobot.assertAuthScreen() +``` + +### 3. `emptyUserNameShowsProperErrorMessage` + +This will be really similar as the previous test, so try to do it on your own. The error is `R.string.username_is_invalid` + +Still, here is the complete code: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +navigationRobot.assertAuthScreen() + +robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotLoading() +navigationRobot.assertAuthScreen() +``` + +### 4. `invalidCredentialsGivenShowsProperErrorMessage` + +Now we verify network errors. First let's setup the response: +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan") +) +``` + +Now input the credentials and fire the event: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +navigationRobot.assertAuthScreen() +robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + +composeTestRule.mainClock.autoAdvance = false +robot.clickOnLogin() +composeTestRule.mainClock.advanceTimeByFrame() +robot.assertLoading() +composeTestRule.mainClock.autoAdvance = true +``` + +Now at the end verify the error is shown properly: +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotLoading() +navigationRobot.assertAuthScreen() +``` + +### 5. `networkErrorShowsProperErrorMessage` + +Finally we verify the `AuthScenario.GenericError`. This will be really similar as the previous, except the error will be `R.string.something_went_wrong`. +You should try to do this on your own. + +Here is the code for verification: +```kotlin +mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") +) + +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +navigationRobot.assertAuthScreen() +robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + +composeTestRule.mainClock.autoAdvance = false +robot.clickOnLogin() +composeTestRule.mainClock.advanceTimeByFrame() +robot.assertLoading() +composeTestRule.mainClock.autoAdvance = true + +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotLoading() +navigationRobot.assertAuthScreen() +``` From 908ab5055373534be863d1156e85bc696b740174 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 12:32:37 +0300 Subject: [PATCH 15/23] Add link to compose.instructionset.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2292ad4..e764921 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,9 @@ In this section we will see how to test component depending on context such as R In this tests we will also see how to interact with View components in tests via Espresso. We will also see how to test a specific Activity (same concept can be applied to fragments) +Bonus: +* Testing Compose UI: Open the [compose instruction set](./codekata/compose.instructionset.md) + #### Robolectric and Android Tests. Open the [shared tests instruction set](./codekata/sharedtests.instructionset). From 1ce0cee3e96ea2f402bdaf2cf74e9b6bc75027c4 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 12:35:44 +0300 Subject: [PATCH 16/23] Remove unused ComposeNetworkSynchronizationTestRule --- .../ComposeNetworkSynchronizationTestRule.kt | 66 ------------------- 1 file changed, 66 deletions(-) delete mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt deleted file mode 100644 index 2cfd4d2..0000000 --- a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/ComposeNetworkSynchronizationTestRule.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.fnives.test.showcase.testutils.idling - -import androidx.annotation.CheckResult -import androidx.compose.ui.test.IdlingResource -import androidx.compose.ui.test.junit4.ComposeTestRule -import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement -import org.koin.test.KoinTest - -class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest { - - private var disposable: Disposable? = null - - override fun apply(base: Statement, description: Description): Statement { - return object : Statement() { - override fun evaluate() { - disposable = registerNetworkingSynchronization() - try { - base.evaluate() - } finally { - dispose() - } - } - } - } - - fun dispose() = disposable?.dispose() - - @CheckResult - private fun registerNetworkingSynchronization(): Disposable { - val idlingResources = NetworkTestConfigurationHelper.getOkHttpClients() - .associateBy(keySelector = { it.toString() }) - .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } - .map { - ComposeIdlingResourceDisposable(composeTestRule, object : IdlingResource { - override val isIdleNow: Boolean - get() { - return it.isIdleNow - } - }) - } - - return CompositeDisposable(idlingResources) - } -} - - -private class ComposeIdlingResourceDisposable( - private val composeTestRule: ComposeTestRule, - private val idlingResource: IdlingResource -) : Disposable { - override var isDisposed: Boolean = false - private set - - init { - composeTestRule.registerIdlingResource(idlingResource) - } - - override fun dispose() { - if (isDisposed) return - isDisposed = true - composeTestRule.unregisterIdlingResource(idlingResource) - } -} \ No newline at end of file From 3034717c117cfa8169063e0b0ac269b546075946 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 12:37:28 +0300 Subject: [PATCH 17/23] Extract constraintlayout-compose version --- app/build.gradle | 2 +- gradlescripts/versions.gradle | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 52d50d4..fb77cfe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -72,7 +72,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" implementation "com.google.android.material:material:$androidx_material_version" implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version" - implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0" + implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version" implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version" diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index 29b40ff..025cce7 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -11,6 +11,7 @@ project.ext { androidx_compose = "1.1.0" google_accompanist = "0.23.1" + androidx_compose_constraintlayout_version = "1.0.0" coroutines_version = "1.6.0" turbine_version = "0.7.0" From 2e97716b4897897ab13f0f8859a8a6e14685c758 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 12:39:32 +0300 Subject: [PATCH 18/23] Add suffix version --- app/build.gradle | 22 +++++++++++----------- gradlescripts/versions.gradle | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fb77cfe..8672964 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,7 +37,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = project.androidx_compose + kotlinCompilerExtensionVersion = project.androidx_compose_version } sourceSets { @@ -81,13 +81,13 @@ dependencies { implementation "androidx.activity:activity-compose:$activity_ktx_version" implementation "androidx.navigation:navigation-compose:$androidx_navigation" - implementation "androidx.compose.ui:ui:$androidx_compose" - implementation "androidx.compose.ui:ui-tooling:$androidx_compose" - implementation "androidx.compose.foundation:foundation:$androidx_compose" - implementation "androidx.compose.material:material:$androidx_compose" - implementation "androidx.compose.animation:animation-graphics:$androidx_compose" - implementation "com.google.accompanist:accompanist-insets:$google_accompanist" - implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist" + implementation "androidx.compose.ui:ui:$androidx_compose_version" + implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version" + implementation "androidx.compose.foundation:foundation:$androidx_compose_version" + implementation "androidx.compose.material:material:$androidx_compose_version" + implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version" + implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version" + implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version" implementation "io.insert-koin:koin-android:$koin_version" implementation "io.insert-koin:koin-androidx-compose:$koin_version" @@ -133,9 +133,9 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" androidTestImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version" - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" - testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose" - debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version" + testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version" + debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose_version" androidTestImplementation project(':mockserver') androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle index 025cce7..7992a10 100644 --- a/gradlescripts/versions.gradle +++ b/gradlescripts/versions.gradle @@ -9,8 +9,8 @@ project.ext { activity_ktx_version = "1.4.0" androidx_navigation = "2.4.0" - androidx_compose = "1.1.0" - google_accompanist = "0.23.1" + androidx_compose_version = "1.1.0" + google_accompanist_version = "0.23.1" androidx_compose_constraintlayout_version = "1.0.0" coroutines_version = "1.6.0" From 47037d4bcd7fec0ed1202e06bb54c6549a7c97f3 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 4 Apr 2022 18:16:06 +0300 Subject: [PATCH 19/23] Add a test for restoring compose content --- .../ui/AuthComposeInstrumentedTest.kt | 21 +++++++++++++- .../compose/screen/auth/AuthScreenState.kt | 27 ++++++++++++++++-- codekata/compose.instructionset.md | 28 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 8fddc98..f768093 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,5 +1,6 @@ package org.fnives.test.showcase.ui +import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R @@ -22,6 +23,7 @@ class AuthComposeInstrumentedTest : KoinTest { @get:Rule val composeTestRule = createComposeRule() + private val stateRestorationTester = StateRestorationTester(composeTestRule) private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup @@ -37,7 +39,7 @@ class AuthComposeInstrumentedTest : KoinTest { @Before fun setup() { - composeTestRule.setContent { + stateRestorationTester.setContent { AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage())) } robot = ComposeLoginRobot(composeTestRule) @@ -152,4 +154,21 @@ class AuthComposeInstrumentedTest : KoinTest { .assertNotLoading() navigationRobot.assertAuthScreen() } + + /** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */ + @Test + fun restoringContentShowPreviousCredentials() { + composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + stateRestorationTester.emulateSavedInstanceStateRestore() + + navigationRobot.assertAuthScreen() + robot.assertUsername("alma") + .assertPassword("banan") + } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt index 92ce536..c0e4840 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt @@ -1,7 +1,9 @@ package org.fnives.test.showcase.compose.screen.auth import androidx.compose.runtime.* -import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -17,7 +19,9 @@ fun rememberAuthScreenState( loginUseCase: LoginUseCase = get(), onLoginSuccess: () -> Unit = {}, ): AuthScreenState { - return remember { AuthScreenState(stateScope, loginUseCase, onLoginSuccess) } + return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess) + } } class AuthScreenState( @@ -80,4 +84,23 @@ class AuthScreenState( UNSUPPORTED_USERNAME, UNSUPPORTED_PASSWORD } + + companion object { + private const val USERNAME = "USERNAME" + private const val PASSWORD = "PASSWORD" + + fun getSaver( + stateScope: CoroutineScope, + loginUseCase: LoginUseCase, + onLoginSuccess: () -> Unit, + ): Saver = mapSaver( + save = { mapOf(USERNAME to it.username, PASSWORD to it.password) }, + restore = { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply { + onUsernameChanged(it.getOrElse(USERNAME) { "" } as String) + onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String) + } + } + ) + } } \ No newline at end of file diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index e927e53..d2c5d8d 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -324,3 +324,31 @@ robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() ``` + +### 6. `restoringContentShowPreviousCredentials` + +Since we're writing apps for Android, we must handle state restoration so let's write a test for it. + +For simulating the recreation of the UI, we first need a `StateRestorationTester`: +```kotlin + private val stateRestorationTester = StateRestorationTester(composeTestRule) +``` + +Then in `setup()`, we need to `setContent` on `stateRestorationTester` instead of on `composeTestRule`. + +Now for the actual test, we first setup the content then we trigger restoration by calling `stateRestorationTester.emulateSavedInstanceStateRestore()`, afterwards we can verify that the content is recreated in the correct way: + +```kotlin +composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } +navigationRobot.assertAuthScreen() +robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + +stateRestorationTester.emulateSavedInstanceStateRestore() + +navigationRobot.assertAuthScreen() +robot.assertUsername("alma") + .assertPassword("banan") +``` \ No newline at end of file From f738a59c2351e213ff7d0b7c882527658e5a4332 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 5 Apr 2022 09:47:06 +0300 Subject: [PATCH 20/23] Revert changes to Event --- app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt index dde6082..30a323c 100644 --- a/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.ui.shared -class Event(private val data: T) { +@Suppress("DataClassContainsFunctions") +data class Event(private val data: T) { private var consumed: Boolean = false From c11d3e96d397a5eacb4f7a7c8318026f3e0b58bf Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Tue, 5 Apr 2022 09:54:13 +0300 Subject: [PATCH 21/23] Fix lint issues --- .../ui/AuthComposeInstrumentedTest.kt | 3 +- .../test/showcase/ui/ComposeLoginRobot.kt | 11 ++- .../showcase/ui/ComposeNavigationRobot.kt | 2 +- .../test/showcase/compose/ComposeActivity.kt | 2 +- .../showcase/compose/screen/AppNavigation.kt | 13 +-- .../compose/screen/auth/AuthScreen.kt | 60 +++++++++---- .../compose/screen/auth/AuthScreenState.kt | 8 +- .../compose/screen/home/HomeScreen.kt | 18 ++-- .../compose/screen/home/HomeScreenState.kt | 4 +- .../compose/screen/splash/SplashScreen.kt | 3 +- .../main/res/drawable/avd_hide_password.xml | 88 ------------------- ...vd_show_password.xml => show_password.xml} | 15 ++-- 12 files changed, 97 insertions(+), 130 deletions(-) delete mode 100644 app/src/main/res/drawable/avd_hide_password.xml rename app/src/main/res/drawable/{avd_show_password.xml => show_password.xml} (85%) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index f768093..4ee0571 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -36,7 +36,6 @@ class AuthComposeInstrumentedTest : KoinTest { val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule) .around(dispatcherTestRule) - @Before fun setup() { stateRestorationTester.setContent { @@ -171,4 +170,4 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertUsername("alma") .assertPassword("banan") } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 53c237d..162b39a 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,8 +1,14 @@ package org.fnives.test.showcase.ui import android.content.Context -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag @@ -44,5 +50,4 @@ class ComposeLoginRobot( composeTestRule.onNodeWithTag(AuthScreenTag.LoginError) .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) } - -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt index 881875f..644f20f 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeNavigationRobot.kt @@ -15,4 +15,4 @@ class ComposeNavigationRobot( fun assertAuthScreen(): ComposeNavigationRobot = apply { composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt b/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt index 5a0b0cd..262718e 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/ComposeActivity.kt @@ -25,4 +25,4 @@ fun TestShowCaseApp() { AppNavigation() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt index 2c1f7cb..b5ae528 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/AppNavigation.kt @@ -34,16 +34,19 @@ fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) { ) { composable("Splash") { SplashScreen() } composable("Auth") { - AuthScreen(modifier = Modifier.testTag(AppNavigationTag.AuthScreen), + AuthScreen( + modifier = Modifier.testTag(AppNavigationTag.AuthScreen), authScreenState = rememberAuthScreenState( onLoginSuccess = { navController.navigate("Home") } - )) + ) + ) } composable("Home") { HomeScreen( - Modifier.testTag(AppNavigationTag.HomeScreen), + modifier = Modifier.testTag(AppNavigationTag.HomeScreen), homeScreenState = rememberHomeScreenState( - onLogout = { navController.navigate("Auth") }) + onLogout = { navController.navigate("Auth") } + ) ) } } @@ -52,4 +55,4 @@ fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) { object AppNavigationTag { const val AuthScreen = "AppNavigationTag.AuthScreen" const val HomeScreen = "AppNavigationTag.HomeScreen" -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt index 708d115..dc2e512 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreen.kt @@ -4,11 +4,29 @@ import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -32,16 +50,23 @@ fun AuthScreen( ConstraintLayout(modifier.fillMaxSize()) { val (title, credentials, snackbar, loading, login) = createRefs() Title( - Modifier + modifier = Modifier .statusBarsPadding() - .constrainAs(title) { top.linkTo(parent.top) }) - CredentialsFields(authScreenState, Modifier.constrainAs(credentials) { - top.linkTo(title.bottom) - bottom.linkTo(login.top) - }) - Snackbar(authScreenState, Modifier.constrainAs(snackbar) { - bottom.linkTo(login.top) - }) + .constrainAs(title) { top.linkTo(parent.top) } + ) + CredentialsFields( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(credentials) { + top.linkTo(title.bottom) + bottom.linkTo(login.top) + } + ) + Snackbar( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(snackbar) { + bottom.linkTo(login.top) + } + ) if (authScreenState.loading) { CircularProgressIndicator( Modifier @@ -49,7 +74,8 @@ fun AuthScreen( .constrainAs(loading) { bottom.linkTo(login.top) centerHorizontallyTo(parent) - }) + } + ) } LoginButton( modifier = Modifier @@ -84,7 +110,7 @@ private fun PasswordField(authScreenState: AuthScreenState) { label = { Text(text = stringResource(id = R.string.password)) }, placeholder = { Text(text = stringResource(id = R.string.password)) }, trailingIcon = { - val image = AnimatedImageVector.animatedVectorResource(R.drawable.avd_show_password) + val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password) Icon( painter = rememberAnimatedVectorPainter(image, passwordVisible), contentDescription = null, @@ -94,7 +120,11 @@ private fun PasswordField(authScreenState: AuthScreenState) { ) }, onValueChange = { authScreenState.onPasswordChanged(it) }, - keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done, keyboardType = KeyboardType.Password), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password + ), keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() authScreenState.onLogin() diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt index c0e4840..35c3f97 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/auth/AuthScreenState.kt @@ -1,9 +1,13 @@ package org.fnives.test.showcase.compose.screen.auth -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -103,4 +107,4 @@ class AuthScreenState( } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt index d09f4a6..34716c1 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreen.kt @@ -2,7 +2,14 @@ package org.fnives.test.showcase.compose.screen.home import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -49,7 +56,8 @@ fun HomeScreen( state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), onRefresh = { homeScreenState.onRefresh() - }) { + } + ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(homeScreenState.content) { item -> Item( @@ -88,15 +96,15 @@ private fun Item( Text(text = favouriteContent.content.title) Text(text = favouriteContent.content.description) } + val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 Image( - painter = painterResource(id = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24), + painter = painterResource(id = favouriteIcon), contentDescription = null, Modifier.clickable { onFavouriteToggle() } ) } } - @Composable private fun Title(modifier: Modifier = Modifier) { Text( @@ -114,4 +122,4 @@ private fun ErrorText(modifier: Modifier = Modifier) { style = MaterialTheme.typography.h4, textAlign = TextAlign.Center ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt index 38a283b..33c21c4 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/home/HomeScreenState.kt @@ -19,6 +19,7 @@ import org.fnives.test.showcase.model.content.FavouriteContent import org.fnives.test.showcase.model.shared.Resource import org.koin.androidx.compose.get +@Suppress("LongParameterList") @Composable fun rememberHomeScreenState( stateScope: CoroutineScope = rememberCoroutineScope(), @@ -42,6 +43,7 @@ fun rememberHomeScreenState( } } +@Suppress("LongParameterList") class HomeScreenState( private val stateScope: CoroutineScope, private val getAllContentUseCase: GetAllContentUseCase, @@ -113,4 +115,4 @@ class HomeScreenState( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt b/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt index aeff842..6a04d6f 100644 --- a/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt +++ b/app/src/main/java/org/fnives/test/showcase/compose/screen/splash/SplashScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import org.fnives.test.showcase.R - @Composable fun SplashScreen() { Box(Modifier.fillMaxSize().background(colorResource(R.color.purple_700)), contentAlignment = Alignment.Center) { @@ -23,4 +22,4 @@ fun SplashScreen() { modifier = Modifier.size(120.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/avd_hide_password.xml b/app/src/main/res/drawable/avd_hide_password.xml deleted file mode 100644 index 89f4cdd..0000000 --- a/app/src/main/res/drawable/avd_hide_password.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_show_password.xml b/app/src/main/res/drawable/show_password.xml similarity index 85% rename from app/src/main/res/drawable/avd_show_password.xml rename to app/src/main/res/drawable/show_password.xml index aed3983..6d35533 100644 --- a/app/src/main/res/drawable/avd_show_password.xml +++ b/app/src/main/res/drawable/show_password.xml @@ -34,18 +34,21 @@ android:pathData="@string/path_password_strike_through" android:strokeColor="@android:color/white" android:strokeLineCap="square" - android:strokeWidth="1.8"/> + android:strokeWidth="1.8" + tools:ignore="PrivateResource" /> + android:pathData="@string/path_password_eye_mask_strike_through" + tools:ignore="PrivateResource" /> + android:pathData="@string/path_password_eye" + tools:ignore="PrivateResource" /> @@ -63,7 +66,8 @@ android:propertyName="pathData" android:valueFrom="@string/path_password_eye_mask_strike_through" android:valueTo="@string/path_password_eye_mask_visible" - android:valueType="pathType"/> + android:valueType="pathType" + tools:ignore="PrivateResource" /> @@ -78,7 +82,8 @@ android:interpolator="@android:interpolator/fast_out_linear_in" android:propertyName="trimPathEnd" android:valueFrom="1" - android:valueTo="0"/> + android:valueTo="0" + tools:ignore="PrivateResource" /> From d83cc23ee9e4e5d3a1cbb45aaf733cf4831c1c4d Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 11 Apr 2022 11:19:17 +0300 Subject: [PATCH 22/23] Improve ComposeLoginRobot and specify the test class that we are following --- .../test/showcase/ui/ComposeLoginRobot.kt | 24 +++++++-------- codekata/compose.instructionset.md | 29 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 162b39a..4a4b1a6 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -13,41 +13,39 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - private val composeTestRule: ComposeTestRule, -) { + composeTestRule: ComposeTestRule, +): ComposeTestRule by composeTestRule { fun setUsername(username: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) } fun setPassword(password: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) } fun assertPassword(password: String): ComposeLoginRobot = apply { - with(composeTestRule) { - onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() - onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) - } + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) } fun assertUsername(username: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) + onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) } fun clickOnLogin(): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoginButton).performClick() + onNodeWithTag(AuthScreenTag.LoginButton).performClick() } fun assertLoading(): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() } fun assertNotLoading(): ComposeLoginRobot = apply { - composeTestRule.onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) } fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoginError) + onNodeWithTag(AuthScreenTag.LoginError) .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) } } diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index d2c5d8d..68b4ca3 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -1,8 +1,8 @@ ## Login UI Test with Compose -This section is equivalent to the one with Login UI Test from robolectric.instructionset.md. +This section is equivalent to the one with ["Login UI Test"](../robolectric.instructionset.md#login-ui-test) from robolectric.instructionset.md. Make sure to read that one first as this one only focuses on the differences that Compose brings. -We will write the same tests so that we see clearly the differences between the two. +We will write the same tests from `AuthActivityInstrumentedTest` so that we see clearly the differences between the two. ### Robot Pattern @@ -21,40 +21,43 @@ Here is a list of actions we want to do: ##### So here is the code for our the UI interactions ```kotlin +class ComposeLoginRobot( + composeTestRule: ComposeTestRule, +): ComposeTestRule by composeTestRule { + fun setUsername(username: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) } fun setPassword(password: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) } fun assertPassword(password: String): ComposeLoginRobot = apply { - with(composeTestRule) { - onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() - onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) - } + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) } fun assertUsername(username: String): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) + onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) } fun clickOnLogin(): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoginButton).performClick() + onNodeWithTag(AuthScreenTag.LoginButton).performClick() } fun assertLoading(): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() } fun assertNotLoading(): ComposeLoginRobot = apply { - composeTestRule.onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) } fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { - composeTestRule.onNodeWithTag(AuthScreenTag.LoginError) + onNodeWithTag(AuthScreenTag.LoginError) .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) } +} ``` While in the View system we're using Espresso to interact with views, From 5f960880dec13c5cdf09430df9201004081d7e79 Mon Sep 17 00:00:00 2001 From: Alex Gabor Date: Mon, 11 Apr 2022 11:29:40 +0300 Subject: [PATCH 23/23] Fix lint warning --- .../java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt | 3 ++- codekata/compose.instructionset.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 4a4b1a6..1791ed1 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -14,7 +14,7 @@ import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( composeTestRule: ComposeTestRule, -): ComposeTestRule by composeTestRule { +) : ComposeTestRule by composeTestRule { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) @@ -40,6 +40,7 @@ class ComposeLoginRobot( fun assertLoading(): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() } + fun assertNotLoading(): ComposeLoginRobot = apply { onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) } diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index 68b4ca3..2de8d66 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -23,7 +23,7 @@ Here is a list of actions we want to do: ```kotlin class ComposeLoginRobot( composeTestRule: ComposeTestRule, -): ComposeTestRule by composeTestRule { +) : ComposeTestRule by composeTestRule { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)