Merge pull request #68 from fknives/compose

Compose
This commit is contained in:
Gergely Hegedis 2022-04-11 13:06:56 +03:00 committed by GitHub
commit 1abd50468f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1458 additions and 2 deletions

View file

@ -34,6 +34,10 @@ android {
buildFeatures {
viewBinding true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = project.androidx_compose_version
}
sourceSets {
@ -68,18 +72,32 @@ 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:$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"
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_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"
implementation "androidx.room:room-runtime:$androidx_room_version"
kapt "androidx.room:room-compiler:$androidx_room_version"
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")
@ -115,6 +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_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"

View file

@ -0,0 +1,173 @@
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
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.DispatcherTestRule
import org.fnives.test.showcase.testutils.idling.anyResourceIdling
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 = createComposeRule()
private val stateRestorationTester = StateRestorationTester(composeTestRule)
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val dispatcherTestRule = DispatcherTestRule()
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(dispatcherTestRule)
@Before
fun setup() {
stateRestorationTester.setContent {
AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage()))
}
robot = ComposeLoginRobot(composeTestRule)
navigationRobot = ComposeNavigationRobot(composeTestRule)
}
/** 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.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.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
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertHomeScreen()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
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.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.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.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()
}
/** 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")
}
}

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.ui
import android.content.Context
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
class ComposeLoginRobot(
composeTestRule: ComposeTestRule,
) : ComposeTestRule by composeTestRule {
fun setUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
}
fun setPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
}
fun assertPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
}
fun assertUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
}
fun clickOnLogin(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
}
fun assertLoading(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
}
fun assertNotLoading(): ComposeLoginRobot = apply {
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
}
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginError)
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
}
}

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase.ui
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import org.fnives.test.showcase.compose.screen.AppNavigationTag
class ComposeNavigationRobot(
private val composeTestRule: ComposeTestRule,
) {
fun assertHomeScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
}
fun assertAuthScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
}
}

View file

@ -25,6 +25,16 @@
</activity>
<activity android:name=".ui.home.MainActivity" />
<activity android:name=".ui.auth.AuthActivity" />
<activity
android:name="org.fnives.test.showcase.compose.ComposeActivity"
android:configChanges="colorMode|density|fontScale|fontWeightAdjustment|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.compose
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.compose.screen.AppNavigation
class ComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestShowCaseApp()
}
}
}
@Composable
fun TestShowCaseApp() {
ProvideWindowInsets {
MaterialTheme {
AppNavigation()
}
}
}

View file

@ -0,0 +1,58 @@
package org.fnives.test.showcase.compose.screen
import androidx.compose.foundation.background
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.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
fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) {
val navController = rememberNavController()
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(
modifier = Modifier.testTag(AppNavigationTag.AuthScreen),
authScreenState = rememberAuthScreenState(
onLoginSuccess = { navController.navigate("Home") }
)
)
}
composable("Home") {
HomeScreen(
modifier = Modifier.testTag(AppNavigationTag.HomeScreen),
homeScreenState = rememberHomeScreenState(
onLogout = { navController.navigate("Auth") }
)
)
}
}
}
object AppNavigationTag {
const val AuthScreen = "AppNavigationTag.AuthScreen"
const val HomeScreen = "AppNavigationTag.HomeScreen"
}

View file

@ -0,0 +1,211 @@
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.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.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
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
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
import org.fnives.test.showcase.R
@Composable
fun AuthScreen(
modifier: Modifier = Modifier,
authScreenState: AuthScreenState = rememberAuthScreenState()
) {
ConstraintLayout(modifier.fillMaxSize()) {
val (title, credentials, snackbar, loading, login) = createRefs()
Title(
modifier = Modifier
.statusBarsPadding()
.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
.testTag(AuthScreenTag.LoadingIndicator)
.constrainAs(loading) {
bottom.linkTo(login.top)
centerHorizontallyTo(parent)
}
)
}
LoginButton(
modifier = Modifier
.constrainAs(login) { bottom.linkTo(parent.bottom) }
.padding(16.dp),
onClick = { authScreenState.onLogin() }
)
}
}
@Composable
private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
Column(
modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
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.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() }
val error = authScreenState.error
LaunchedEffect(error) {
if (error != null) {
snackbarState.showSnackbar(error.name)
authScreenState.dismissError()
}
}
SnackbarHost(hostState = snackbarState, modifier) {
val stringId = error?.stringResId()
if (stringId != null) {
Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError))
}
}
}
}
@Composable
private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(modifier) {
Button(
onClick = onClick,
Modifier
.fillMaxWidth()
.testTag(AuthScreenTag.LoginButton)
) {
Text(text = "Login")
}
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
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
}
object AuthScreenTag {
const val UsernameInput = "AuthScreenTag.UsernameInput"
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"
}

View file

@ -0,0 +1,110 @@
package org.fnives.test.showcase.compose.screen.auth
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
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.koin.androidx.compose.get
@Composable
fun rememberAuthScreenState(
stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main },
loginUseCase: LoginUseCase = get(),
onLoginSuccess: () -> Unit = {},
): AuthScreenState {
return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) {
AuthScreenState(stateScope, loginUseCase, onLoginSuccess)
}
}
class AuthScreenState(
private val stateScope: CoroutineScope,
private val loginUseCase: LoginUseCase,
private val onLoginSuccess: () -> Unit = {},
) {
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<ErrorType?>(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 = ErrorType.GENERAL_NETWORK_ERROR
is Answer.Success -> processLoginStatus(response.data)
}
loading = false
}
}
private fun processLoginStatus(loginStatus: LoginStatus) {
when (loginStatus) {
LoginStatus.SUCCESS -> onLoginSuccess()
LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS
LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME
LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD
}
}
fun dismissError() {
error = null
}
enum class ErrorType {
INVALID_CREDENTIALS,
GENERAL_NETWORK_ERROR,
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<AuthScreenState, *> = 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)
}
}
)
}
}

View file

@ -0,0 +1,125 @@
package org.fnives.test.showcase.compose.screen.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
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
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.text.style.TextAlign
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(
modifier: Modifier = Modifier,
homeScreenState: HomeScreenState = rememberHomeScreenState()
) {
Column(modifier.fillMaxSize()) {
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() }
)
}
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) }
)
}
}
}
}
}
}
@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)
}
val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
Image(
painter = painterResource(id = favouriteIcon),
contentDescription = null,
Modifier.clickable { onFavouriteToggle() }
)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.login_title),
modifier = modifier.padding(16.dp),
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
)
}

View file

@ -0,0 +1,118 @@
package org.fnives.test.showcase.compose.screen.home
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
@Suppress("LongParameterList")
@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,
onLogout,
)
}
}
@Suppress("LongParameterList")
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 logoutEvent: () -> Unit,
) {
var loading by mutableStateOf(false)
private set
var isError by mutableStateOf(false)
private set
var content by mutableStateOf<List<FavouriteContent>>(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<FavouriteContent>()
}
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)
}
}
}
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.compose.screen.splash
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
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) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(120.dp)
)
}
}

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="NewApi">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp">
<path
android:name="strike_through"
android:pathData="@string/path_password_strike_through"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"
tools:ignore="PrivateResource" />
<group>
<clip-path
android:name="eye_mask"
android:pathData="@string/path_password_eye_mask_strike_through"
tools:ignore="PrivateResource" />
<path
android:fillColor="@android:color/white"
android:name="eye"
android:pathData="@string/path_password_eye"
tools:ignore="PrivateResource" />
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="pathData"
android:valueFrom="@string/path_password_eye_mask_strike_through"
android:valueTo="@string/path_password_eye_mask_visible"
android:valueType="pathType"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
</animated-vector>

View file

@ -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

View file

@ -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 DispatcherTestRule : 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()
}
}
}