mirror of
https://github.com/bitwarden/android.git
synced 2024-11-21 08:55:48 +03:00
BIT-141 Setup basic Login and Landing screens (#40)
Co-authored-by: Caleb Derosier <caleb@livefront.com>
This commit is contained in:
parent
d8de9bb753
commit
024376b0d2
13 changed files with 727 additions and 30 deletions
|
@ -8,7 +8,10 @@ import androidx.navigation.navigation
|
|||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestinations
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestinations
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding
|
||||
import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations
|
||||
import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
|
||||
|
||||
const val AUTH_ROUTE: String = "auth"
|
||||
|
||||
|
@ -21,8 +24,12 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
|
|||
route = AUTH_ROUTE,
|
||||
) {
|
||||
createAccountDestinations()
|
||||
landingDestination(
|
||||
landingDestinations(
|
||||
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
|
||||
onNavigateToLogin = { emailAddress -> navController.navigateToLogin(emailAddress) },
|
||||
)
|
||||
loginDestinations(
|
||||
onNavigateToLanding = { navController.navigateToLanding() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,8 +17,14 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
|
|||
/**
|
||||
* Add the Landing screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.landingDestination(onNavigateToCreateAccount: () -> Unit) {
|
||||
fun NavGraphBuilder.landingDestinations(
|
||||
onNavigateToCreateAccount: () -> Unit,
|
||||
onNavigateToLogin: (String) -> Unit,
|
||||
) {
|
||||
composable(route = LANDING_ROUTE) {
|
||||
LandingScreen(onNavigateToCreateAccount = onNavigateToCreateAccount)
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = onNavigateToCreateAccount,
|
||||
onNavigateToLogin = onNavigateToLogin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,12 +34,14 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
|||
@Suppress("LongMethod")
|
||||
fun LandingScreen(
|
||||
onNavigateToCreateAccount: () -> Unit,
|
||||
onNavigateToLogin: (emailAddress: String) -> Unit,
|
||||
viewModel: LandingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
|
||||
is LandingEvent.NavigateToLogin -> onNavigateToLogin(event.emailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,8 +70,10 @@ fun LandingScreen(
|
|||
)
|
||||
|
||||
BitwardenTextField(
|
||||
modifier = Modifier.testTag("Email address"),
|
||||
value = state.emailInput,
|
||||
onValueChange = { viewModel.trySendAction(LandingAction.EmailInputChanged(it)) },
|
||||
label = stringResource(id = R.string.email_address),
|
||||
initialValue = state.initialEmailAddress,
|
||||
)
|
||||
|
||||
Row(
|
||||
|
@ -87,6 +91,7 @@ fun LandingScreen(
|
|||
)
|
||||
|
||||
Switch(
|
||||
modifier = Modifier.testTag("Remember me"),
|
||||
checked = state.isRememberMeEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.trySendAction(LandingAction.RememberMeToggle(it))
|
||||
|
@ -95,7 +100,9 @@ fun LandingScreen(
|
|||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.trySendAction(LandingAction.ContinueButtonClick) },
|
||||
onClick = {
|
||||
viewModel.trySendAction(LandingAction.ContinueButtonClick)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
|
|
|
@ -1,34 +1,55 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.landing
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* Manages application state for the initial landing screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LandingViewModel @Inject constructor() :
|
||||
BaseViewModel<LandingState, LandingEvent, LandingAction>(
|
||||
initialState = LandingState(
|
||||
initialEmailAddress = "",
|
||||
class LandingViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
) {
|
||||
) {
|
||||
|
||||
init {
|
||||
// As state updates, write to saved state handle:
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LandingAction) {
|
||||
when (action) {
|
||||
LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
|
||||
is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
|
||||
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
|
||||
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action)
|
||||
is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) {
|
||||
mutableStateFlow.update { it.copy(emailInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleContinueButtonClicked() {
|
||||
mutableStateFlow.value = mutableStateFlow.value.copy(
|
||||
isContinueButtonEnabled = false,
|
||||
)
|
||||
sendEvent(LandingEvent.NavigateToLogin(mutableStateFlow.value.emailInput))
|
||||
}
|
||||
|
||||
private fun handleCreateAccountClicked() {
|
||||
|
@ -36,20 +57,19 @@ class LandingViewModel @Inject constructor() :
|
|||
}
|
||||
|
||||
private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) {
|
||||
mutableStateFlow.value = mutableStateFlow.value.copy(
|
||||
isRememberMeEnabled = action.isChecked,
|
||||
)
|
||||
mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state of the landing screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LandingState(
|
||||
val initialEmailAddress: String,
|
||||
val emailInput: String,
|
||||
val isContinueButtonEnabled: Boolean,
|
||||
val isRememberMeEnabled: Boolean,
|
||||
)
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Models events for the landing screen.
|
||||
|
@ -59,6 +79,13 @@ sealed class LandingEvent {
|
|||
* Navigates to the Create Account screen.
|
||||
*/
|
||||
data object NavigateToCreateAccount : LandingEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the Login screen with the given email address.
|
||||
*/
|
||||
data class NavigateToLogin(
|
||||
val emailAddress: String,
|
||||
) : LandingEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,4 +108,11 @@ sealed class LandingAction {
|
|||
data class RememberMeToggle(
|
||||
val isChecked: Boolean,
|
||||
) : LandingAction()
|
||||
|
||||
/**
|
||||
* Indicates that the input on the email field has changed.
|
||||
*/
|
||||
data class EmailInputChanged(
|
||||
val input: String,
|
||||
) : LandingAction()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
|
||||
private const val EMAIL_ADDRESS: String = "email_address"
|
||||
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}"
|
||||
|
||||
/**
|
||||
* Class to retrieve login arguments from the [SavedStateHandle].
|
||||
*/
|
||||
class LoginArgs(val emailAddress: String) {
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the login screen with the given email address.
|
||||
*/
|
||||
fun NavController.navigateToLogin(
|
||||
emailAddress: String,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate("login/$emailAddress", navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Login screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.loginDestinations(
|
||||
onNavigateToLanding: () -> Unit,
|
||||
) {
|
||||
composable(
|
||||
route = LOGIN_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
LoginScreen(
|
||||
onNavigateToLanding = { onNavigateToLanding() },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
||||
|
||||
/**
|
||||
* The top level composable for the Login screen.
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun LoginScreen(
|
||||
onNavigateToLanding: () -> Unit,
|
||||
viewModel: LoginViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LoginEvent.NavigateToLanding -> onNavigateToLanding()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||
) {
|
||||
|
||||
BitwardenTextField(
|
||||
modifier = Modifier.testTag("Master password"),
|
||||
value = state.passwordInput,
|
||||
onValueChange = { viewModel.trySendAction(LoginAction.PasswordInputChanged(it)) },
|
||||
label = stringResource(id = R.string.master_password),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.trySendAction(LoginAction.LoginButtonClick) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.testTag("Login button"),
|
||||
enabled = state.isLoginButtonEnabled,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.log_in_with_master_password),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.trySendAction(LoginAction.SingleSignOnClick) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.testTag("Single sign-on button"),
|
||||
enabled = state.isLoginButtonEnabled,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.enterprise_single_sign_on),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.logging_in_as, state.emailAddress),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable { viewModel.trySendAction(LoginAction.NotYouButtonClick) },
|
||||
text = stringResource(id = R.string.not_you),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* Manages application state for the initial login screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: LoginState(
|
||||
emailAddress = LoginArgs(savedStateHandle).emailAddress,
|
||||
isLoginButtonEnabled = false,
|
||||
passwordInput = "",
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
// As state updates, write to saved state handle:
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LoginAction) {
|
||||
when (action) {
|
||||
LoginAction.LoginButtonClick -> handleLoginButtonClicked()
|
||||
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
|
||||
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
|
||||
is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLoginButtonClicked() {
|
||||
// TODO BIT-133 make login request and allow user to login
|
||||
}
|
||||
|
||||
private fun handleNotYouButtonClicked() {
|
||||
sendEvent(LoginEvent.NavigateToLanding)
|
||||
}
|
||||
|
||||
private fun handleSingleSignOnClicked() {
|
||||
// TODO BIT-204 navigate to single sign on
|
||||
}
|
||||
|
||||
private fun handlePasswordInputChanged(action: LoginAction.PasswordInputChanged) {
|
||||
mutableStateFlow.update { it.copy(passwordInput = action.input) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state of the login screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LoginState(
|
||||
val passwordInput: String,
|
||||
val emailAddress: String,
|
||||
val isLoginButtonEnabled: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Models events for the login screen.
|
||||
*/
|
||||
sealed class LoginEvent {
|
||||
/**
|
||||
* Navigates to the Landing screen.
|
||||
*/
|
||||
data object NavigateToLanding : LoginEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the login screen.
|
||||
*/
|
||||
sealed class LoginAction {
|
||||
/**
|
||||
* Indicates that the Login button has been clicked.
|
||||
*/
|
||||
data object LoginButtonClick : LoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the "Not you?" text was clicked.
|
||||
*/
|
||||
data object NotYouButtonClick : LoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the Enterprise single sign-on button has been clicked.
|
||||
*/
|
||||
data object SingleSignOnClick : LoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the password input has changed.
|
||||
*/
|
||||
data class PasswordInputChanged(val input: String) : LoginAction()
|
||||
}
|
|
@ -19,7 +19,10 @@ import androidx.compose.ui.unit.dp
|
|||
* @param label label for the text field.
|
||||
* @param initialValue initial input text.
|
||||
* @param onTextChange callback that is triggered when the input of the text field changes.
|
||||
*
|
||||
* TODO: remove deprecated version: BIT-289
|
||||
*/
|
||||
@Deprecated(message = "Use overloaded BitwardenTextField that takes an input instead of an initialText.")
|
||||
@Composable
|
||||
fun BitwardenTextField(
|
||||
label: String,
|
||||
|
@ -39,3 +42,28 @@ fun BitwardenTextField(
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that allows the user to input text. This composable will manage the state of
|
||||
* the user's input.
|
||||
* @param label label for the text field.
|
||||
* @param value current next on the text field.
|
||||
* @param modifier modifier for the composable.
|
||||
* @param onValueChange callback that is triggered when the input of the text field changes.
|
||||
*/
|
||||
@Composable
|
||||
fun BitwardenTextField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TextField(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
label = { Text(label) },
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -39,6 +39,13 @@
|
|||
<string name="length">Length</string>
|
||||
<string name="overflow_menu">Overflow menu</string>
|
||||
|
||||
<!-- Login screen -->
|
||||
<string name="enterprise_single_sign_on">Enterprise single sign-on</string>
|
||||
<string name="log_in_with_master_password">Log in with master password</string>
|
||||
<string name="logging_in_as">Logging in as %s on bitwarden.com</string>
|
||||
<string name="master_password">Master password</string>
|
||||
<string name="not_you">Not you?</string>
|
||||
|
||||
<!--Bottom Navigation-->
|
||||
<string name="generator_tab_content_description">Press to navigate to the generator screen.</string>
|
||||
<string name="send_tab_content_description">Press to navigate to the send screen.</string>
|
||||
|
|
|
@ -1,33 +1,37 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.landing
|
||||
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
|
||||
class LandingScreenTest : BaseComposeTest() {
|
||||
|
||||
@Test
|
||||
fun `continue button click should send ContinueButtonClicked action`() {
|
||||
fun `continue button click should send ContinueButtonClick action`() {
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
initialEmailAddress = "",
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
every { trySendAction(LandingAction.ContinueButtonClick) } returns Unit
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = {},
|
||||
onNavigateToLogin = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
@ -36,4 +40,127 @@ class LandingScreenTest : BaseComposeTest() {
|
|||
viewModel.trySendAction(LandingAction.ContinueButtonClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remember me click should send RememberMeToggle action`() {
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = {},
|
||||
onNavigateToLogin = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithTag("Remember me").performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(LandingAction.RememberMeToggle(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create account click should send CreateAccountClick action`() {
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = {},
|
||||
onNavigateToLogin = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Create account").performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(LandingAction.CreateAccountClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email address change should send EmailInputChanged action`() {
|
||||
val input = "email"
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = {},
|
||||
onNavigateToLogin = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithTag("Email address").performTextInput(input)
|
||||
verify {
|
||||
viewModel.trySendAction(LandingAction.EmailInputChanged(input))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToCreateAccount event should call onNavigateToCreateAccount`() {
|
||||
var onNavigateToCreateAccountCalled = false
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns flowOf(LandingEvent.NavigateToCreateAccount)
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true },
|
||||
onNavigateToLogin = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
assert(onNavigateToCreateAccountCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToLogin event should call onNavigateToLogin`() {
|
||||
val testEmail = "test@test.com"
|
||||
var onNavigateToLoginEmail = ""
|
||||
val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns flowOf(LandingEvent.NavigateToLogin(testEmail))
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LandingState(
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LandingScreen(
|
||||
onNavigateToCreateAccount = { },
|
||||
onNavigateToLogin = { onNavigateToLoginEmail = it },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
assertEquals(testEmail, onNavigateToLoginEmail)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.landing
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -9,20 +10,42 @@ import org.junit.jupiter.api.Test
|
|||
class LandingViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `ContinueButtonClick should disable continue button`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
fun `initial state should be correct`() = runTest {
|
||||
val viewModel = LandingViewModel(SavedStateHandle())
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should pull from saved state handle when present`() = runTest {
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
emailInput = "test",
|
||||
isContinueButtonEnabled = false,
|
||||
isRememberMeEnabled = true,
|
||||
)
|
||||
val handle = SavedStateHandle(mapOf("state" to expectedState))
|
||||
val viewModel = LandingViewModel(handle)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(expectedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueButtonClick should emit NavigateToLogin`() = runTest {
|
||||
val viewModel = LandingViewModel(SavedStateHandle())
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
|
||||
assertEquals(
|
||||
viewModel.stateFlow.value,
|
||||
DEFAULT_STATE.copy(isContinueButtonEnabled = false),
|
||||
LandingEvent.NavigateToLogin(""),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
val viewModel = LandingViewModel(SavedStateHandle())
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.CreateAccountClick)
|
||||
assertEquals(
|
||||
|
@ -34,7 +57,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `RememberMeToggle should update value of isRememberMeToggled`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
val viewModel = LandingViewModel(SavedStateHandle())
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.RememberMeToggle(true))
|
||||
assertEquals(
|
||||
|
@ -44,9 +67,23 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EmailInputUpdated should update value of email input`() = runTest {
|
||||
val input = "input"
|
||||
val viewModel = LandingViewModel(SavedStateHandle())
|
||||
viewModel.stateFlow.test {
|
||||
awaitItem()
|
||||
viewModel.trySendAction(LandingAction.EmailInputChanged(input))
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(emailInput = input),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = LandingState(
|
||||
initialEmailAddress = "",
|
||||
emailInput = "",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = false,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest : BaseComposeTest() {
|
||||
|
||||
@Test
|
||||
fun `Not you text click should send NotYouButtonClick action`() {
|
||||
val viewModel = mockk<LoginViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LoginState(
|
||||
emailAddress = "",
|
||||
isLoginButtonEnabled = false,
|
||||
passwordInput = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LoginScreen(
|
||||
onNavigateToLanding = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Not you?").performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(LoginAction.NotYouButtonClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password input change should send PasswordInputChanged action`() {
|
||||
val input = "input"
|
||||
val viewModel = mockk<LoginViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LoginState(
|
||||
emailAddress = "",
|
||||
isLoginButtonEnabled = false,
|
||||
passwordInput = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LoginScreen(
|
||||
onNavigateToLanding = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Master password").performTextInput(input)
|
||||
verify {
|
||||
viewModel.trySendAction(LoginAction.PasswordInputChanged(input))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToLanding should call onNavigateToLanding`() {
|
||||
var onNavigateToLandingCalled = false
|
||||
val viewModel = mockk<LoginViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns flowOf(LoginEvent.NavigateToLanding)
|
||||
every { stateFlow } returns MutableStateFlow(
|
||||
LoginState(
|
||||
emailAddress = "",
|
||||
isLoginButtonEnabled = false,
|
||||
passwordInput = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule.setContent {
|
||||
LoginScreen(
|
||||
onNavigateToLanding = { onNavigateToLandingCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
assertTrue(onNavigateToLandingCalled)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class LoginViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val savedStateHandle = SavedStateHandle().also {
|
||||
it["email_address"] = "test@gmail.com"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
val viewModel = LoginViewModel(savedStateHandle)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should pull from handle when present`() = runTest {
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
passwordInput = "input",
|
||||
isLoginButtonEnabled = true,
|
||||
)
|
||||
val handle = SavedStateHandle(
|
||||
mapOf(
|
||||
"email_address" to "test@gmail.com",
|
||||
"state" to expectedState,
|
||||
),
|
||||
)
|
||||
val viewModel = LoginViewModel(handle)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(expectedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LoginButtonClick should do nothing`() = runTest {
|
||||
val viewModel = LoginViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SingleSignOnClick should do nothing`() = runTest {
|
||||
val viewModel = LoginViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LoginAction.SingleSignOnClick)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NotYouButtonClick should emit NavigateToLanding`() = runTest {
|
||||
val viewModel = LoginViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LoginAction.NotYouButtonClick)
|
||||
assertEquals(
|
||||
LoginEvent.NavigateToLanding,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordInputChanged should update password input`() = runTest {
|
||||
val input = "input"
|
||||
val viewModel = LoginViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LoginAction.PasswordInputChanged(input))
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(passwordInput = input),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = LoginState(
|
||||
emailAddress = "test@gmail.com",
|
||||
passwordInput = "",
|
||||
isLoginButtonEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue