commit
1abd50468f
18 changed files with 1458 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
92
app/src/main/res/drawable/show_password.xml
Normal file
92
app/src/main/res/drawable/show_password.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue