BIT-141 Setup basic Login and Landing screens (#40)

Co-authored-by: Caleb Derosier <caleb@livefront.com>
This commit is contained in:
Andrew Haisting 2023-09-11 14:44:09 -05:00 committed by Álison Fernandes
parent d8de9bb753
commit 024376b0d2
13 changed files with 727 additions and 30 deletions

View file

@ -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.createAccountDestinations
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount 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.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" const val AUTH_ROUTE: String = "auth"
@ -21,8 +24,12 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
route = AUTH_ROUTE, route = AUTH_ROUTE,
) { ) {
createAccountDestinations() createAccountDestinations()
landingDestination( landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() }, onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress -> navController.navigateToLogin(emailAddress) },
)
loginDestinations(
onNavigateToLanding = { navController.navigateToLanding() },
) )
} }
} }

View file

@ -17,8 +17,14 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
/** /**
* Add the Landing screen to the nav graph. * 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) { composable(route = LANDING_ROUTE) {
LandingScreen(onNavigateToCreateAccount = onNavigateToCreateAccount) LandingScreen(
onNavigateToCreateAccount = onNavigateToCreateAccount,
onNavigateToLogin = onNavigateToLogin,
)
} }
} }

View file

@ -34,12 +34,14 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod") @Suppress("LongMethod")
fun LandingScreen( fun LandingScreen(
onNavigateToCreateAccount: () -> Unit, onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
viewModel: LandingViewModel = hiltViewModel(), viewModel: LandingViewModel = hiltViewModel(),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount() LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
is LandingEvent.NavigateToLogin -> onNavigateToLogin(event.emailAddress)
} }
} }
@ -68,8 +70,10 @@ fun LandingScreen(
) )
BitwardenTextField( BitwardenTextField(
modifier = Modifier.testTag("Email address"),
value = state.emailInput,
onValueChange = { viewModel.trySendAction(LandingAction.EmailInputChanged(it)) },
label = stringResource(id = R.string.email_address), label = stringResource(id = R.string.email_address),
initialValue = state.initialEmailAddress,
) )
Row( Row(
@ -87,6 +91,7 @@ fun LandingScreen(
) )
Switch( Switch(
modifier = Modifier.testTag("Remember me"),
checked = state.isRememberMeEnabled, checked = state.isRememberMeEnabled,
onCheckedChange = { onCheckedChange = {
viewModel.trySendAction(LandingAction.RememberMeToggle(it)) viewModel.trySendAction(LandingAction.RememberMeToggle(it))
@ -95,7 +100,9 @@ fun LandingScreen(
} }
Button( Button(
onClick = { viewModel.trySendAction(LandingAction.ContinueButtonClick) }, onClick = {
viewModel.trySendAction(LandingAction.ContinueButtonClick)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)

View file

@ -1,34 +1,55 @@
package com.x8bit.bitwarden.ui.auth.feature.landing 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 com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel 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 import javax.inject.Inject
private const val KEY_STATE = "state"
/** /**
* Manages application state for the initial landing screen. * Manages application state for the initial landing screen.
*/ */
@HiltViewModel @HiltViewModel
class LandingViewModel @Inject constructor() : class LandingViewModel @Inject constructor(
BaseViewModel<LandingState, LandingEvent, LandingAction>( savedStateHandle: SavedStateHandle,
initialState = LandingState( ) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialEmailAddress = "", initialState = savedStateHandle[KEY_STATE]
?: LandingState(
emailInput = "",
isContinueButtonEnabled = true, isContinueButtonEnabled = true,
isRememberMeEnabled = false, isRememberMeEnabled = false,
), ),
) { ) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: LandingAction) { override fun handleAction(action: LandingAction) {
when (action) { when (action) {
LandingAction.ContinueButtonClick -> handleContinueButtonClicked() is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
LandingAction.CreateAccountClick -> handleCreateAccountClicked() LandingAction.CreateAccountClick -> handleCreateAccountClicked()
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action) 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() { private fun handleContinueButtonClicked() {
mutableStateFlow.value = mutableStateFlow.value.copy( sendEvent(LandingEvent.NavigateToLogin(mutableStateFlow.value.emailInput))
isContinueButtonEnabled = false,
)
} }
private fun handleCreateAccountClicked() { private fun handleCreateAccountClicked() {
@ -36,20 +57,19 @@ class LandingViewModel @Inject constructor() :
} }
private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) { private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) {
mutableStateFlow.value = mutableStateFlow.value.copy( mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) }
isRememberMeEnabled = action.isChecked,
)
} }
} }
/** /**
* Models state of the landing screen. * Models state of the landing screen.
*/ */
@Parcelize
data class LandingState( data class LandingState(
val initialEmailAddress: String, val emailInput: String,
val isContinueButtonEnabled: Boolean, val isContinueButtonEnabled: Boolean,
val isRememberMeEnabled: Boolean, val isRememberMeEnabled: Boolean,
) ) : Parcelable
/** /**
* Models events for the landing screen. * Models events for the landing screen.
@ -59,6 +79,13 @@ sealed class LandingEvent {
* Navigates to the Create Account screen. * Navigates to the Create Account screen.
*/ */
data object NavigateToCreateAccount : LandingEvent() 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( data class RememberMeToggle(
val isChecked: Boolean, val isChecked: Boolean,
) : LandingAction() ) : LandingAction()
/**
* Indicates that the input on the email field has changed.
*/
data class EmailInputChanged(
val input: String,
) : LandingAction()
} }

View file

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

View file

@ -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,
)
}
}

View file

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

View file

@ -19,7 +19,10 @@ import androidx.compose.ui.unit.dp
* @param label label for the text field. * @param label label for the text field.
* @param initialValue initial input text. * @param initialValue initial input text.
* @param onTextChange callback that is triggered when the input of the text field changes. * @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 @Composable
fun BitwardenTextField( fun BitwardenTextField(
label: String, 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,
)
}

View file

@ -39,6 +39,13 @@
<string name="length">Length</string> <string name="length">Length</string>
<string name="overflow_menu">Overflow menu</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--> <!--Bottom Navigation-->
<string name="generator_tab_content_description">Press to navigate to the generator screen.</string> <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> <string name="send_tab_content_description">Press to navigate to the send screen.</string>

View file

@ -1,33 +1,37 @@
package com.x8bit.bitwarden.ui.auth.feature.landing package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class LandingScreenTest : BaseComposeTest() { class LandingScreenTest : BaseComposeTest() {
@Test @Test
fun `continue button click should send ContinueButtonClicked action`() { fun `continue button click should send ContinueButtonClick action`() {
val viewModel = mockk<LandingViewModel>(relaxed = true) { val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow() every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow( every { stateFlow } returns MutableStateFlow(
LandingState( LandingState(
initialEmailAddress = "", emailInput = "",
isContinueButtonEnabled = true, isContinueButtonEnabled = true,
isRememberMeEnabled = false, isRememberMeEnabled = false,
), ),
) )
every { trySendAction(LandingAction.ContinueButtonClick) } returns Unit
} }
composeTestRule.setContent { composeTestRule.setContent {
LandingScreen( LandingScreen(
onNavigateToCreateAccount = {}, onNavigateToCreateAccount = {},
onNavigateToLogin = {},
viewModel = viewModel, viewModel = viewModel,
) )
} }
@ -36,4 +40,127 @@ class LandingScreenTest : BaseComposeTest() {
viewModel.trySendAction(LandingAction.ContinueButtonClick) 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)
}
} }

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.landing package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -9,20 +10,42 @@ import org.junit.jupiter.api.Test
class LandingViewModelTest : BaseViewModelTest() { class LandingViewModelTest : BaseViewModelTest() {
@Test @Test
fun `ContinueButtonClick should disable continue button`() = runTest { fun `initial state should be correct`() = runTest {
val viewModel = LandingViewModel() 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.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick) viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
assertEquals( assertEquals(
viewModel.stateFlow.value, LandingEvent.NavigateToLogin(""),
DEFAULT_STATE.copy(isContinueButtonEnabled = false), awaitItem(),
) )
} }
} }
@Test @Test
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest { fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
val viewModel = LandingViewModel() val viewModel = LandingViewModel(SavedStateHandle())
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.CreateAccountClick) viewModel.actionChannel.trySend(LandingAction.CreateAccountClick)
assertEquals( assertEquals(
@ -34,7 +57,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test @Test
fun `RememberMeToggle should update value of isRememberMeToggled`() = runTest { fun `RememberMeToggle should update value of isRememberMeToggled`() = runTest {
val viewModel = LandingViewModel() val viewModel = LandingViewModel(SavedStateHandle())
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.RememberMeToggle(true)) viewModel.actionChannel.trySend(LandingAction.RememberMeToggle(true))
assertEquals( 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 { companion object {
private val DEFAULT_STATE = LandingState( private val DEFAULT_STATE = LandingState(
initialEmailAddress = "", emailInput = "",
isContinueButtonEnabled = true, isContinueButtonEnabled = true,
isRememberMeEnabled = false, isRememberMeEnabled = false,
) )

View file

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

View file

@ -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,
)
}
}