From 024376b0d2152239b2018add0f133d73caa71320 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Mon, 11 Sep 2023 14:44:09 -0500 Subject: [PATCH] BIT-141 Setup basic Login and Landing screens (#40) Co-authored-by: Caleb Derosier --- .../ui/auth/feature/auth/AuthNavigation.kt | 11 +- .../auth/feature/landing/LandingNavigation.kt | 10 +- .../ui/auth/feature/landing/LandingScreen.kt | 11 +- .../auth/feature/landing/LandingViewModel.kt | 62 ++++++-- .../ui/auth/feature/login/LoginNavigation.kt | 49 +++++++ .../ui/auth/feature/login/LoginScreen.kt | 100 +++++++++++++ .../ui/auth/feature/login/LoginViewModel.kt | 107 ++++++++++++++ .../platform/components/BitwardenTextField.kt | 28 ++++ app/src/main/res/values/strings.xml | 7 + .../auth/feature/landing/LandingScreenTest.kt | 133 +++++++++++++++++- .../feature/landing/LandingViewModelTest.kt | 51 ++++++- .../ui/auth/feature/login/LoginScreenTest.kt | 88 ++++++++++++ .../auth/feature/login/LoginViewModelTest.kt | 100 +++++++++++++ 13 files changed, 727 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 43b33f8d3..935a49bdd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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() }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt index 8e1a1bdc8..6666d3e1e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index 7e93c295f..9660ccb0c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt index 1aa8230d4..3fc102986 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt @@ -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( - initialState = LandingState( - initialEmailAddress = "", +class LandingViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + 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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt new file mode 100644 index 000000000..9640349eb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -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() }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt new file mode 100644 index 000000000..414cac871 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt new file mode 100644 index 000000000..8ad847a0f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt index 337545324..ba99ddade 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt @@ -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, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c92184c9a..3f7f17488 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,13 @@ Length Overflow menu + + Enterprise single sign-on + Log in with master password + Logging in as %s on bitwarden.com + Master password + Not you? + Press to navigate to the generator screen. Press to navigate to the send screen. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt index 39e7de456..a401f8575 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt @@ -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(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(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(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(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(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(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) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 281340d9c..8710b097c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt new file mode 100644 index 000000000..655d12379 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -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(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(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(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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt new file mode 100644 index 000000000..3e9799d1d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -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, + ) + } +}