From 075956ce179cdaaaee117ac7193b2d513115c792 Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:32:28 -0400 Subject: [PATCH] PM-10617 + PM-10637 update complete registration screen to match new onboarding design (#3787) --- .../ui/auth/feature/auth/AuthNavigation.kt | 17 +- .../CompleteRegistrationNavigation.kt | 8 +- .../CompleteRegistrationScreen.kt | 349 ++++++++++++------ .../CompleteRegistrationViewModel.kt | 139 ++++--- .../handlers/CompleteRegistrationHandler.kt | 86 +++++ .../createaccount/CreateAccountViewModel.kt | 2 +- .../MasterPasswordGuidanceScreen.kt | 63 +--- .../components/card/BitwardenActionCard.kt | 119 ++++++ app/src/main/res/drawable-night/lock.xml | 66 ++++ app/src/main/res/drawable/lock.xml | 66 ++++ app/src/main/res/values/strings.xml | 5 + .../CompleteRegistrationScreenTest.kt | 152 ++++++-- .../CompleteRegistrationViewModelTest.kt | 110 +++--- 13 files changed, 883 insertions(+), 299 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/handlers/CompleteRegistrationHandler.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt create mode 100644 app/src/main/res/drawable-night/lock.xml create mode 100644 app/src/main/res/drawable/lock.xml 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 6ac313d6d..119f9453d 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 @@ -27,6 +27,7 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDe import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.masterPasswordGeneratorDestination import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.navigateToMasterPasswordGenerator import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.masterPasswordGuidanceDestination +import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.navigateToMasterPasswordGuidance import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout @@ -83,8 +84,20 @@ fun NavGraphBuilder.authGraph( ) completeRegistrationDestination( onNavigateBack = { navController.popBackStack() }, - onNavigateToLanding = { - navController.popBackStack(route = LANDING_ROUTE, inclusive = false) + onNavigateToPasswordGuidance = { + navController.navigateToMasterPasswordGuidance() + }, + onNavigateToPreventAccountLockout = { + navController.navigateToPreventAccountLockout() + }, + onNavigateToLogin = { emailAddress, captchaToken -> + navController.navigateToLogin( + emailAddress = emailAddress, + captchaToken = captchaToken, + navOptions = navOptions { + popUpTo(LANDING_ROUTE) + }, + ) }, ) enterpriseSignOnDestination( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt index b4b61094b..c4ff47807 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt @@ -52,7 +52,9 @@ fun NavController.navigateToCompleteRegistration( */ fun NavGraphBuilder.completeRegistrationDestination( onNavigateBack: () -> Unit, - onNavigateToLanding: () -> Unit, + onNavigateToPasswordGuidance: () -> Unit, + onNavigateToPreventAccountLockout: () -> Unit, + onNavigateToLogin: (email: String, token: String) -> Unit, ) { composableWithSlideTransitions( route = COMPLETE_REGISTRATION_ROUTE, @@ -64,7 +66,9 @@ fun NavGraphBuilder.completeRegistrationDestination( ) { CompleteRegistrationScreen( onNavigateBack = onNavigateBack, - onNavigateToLanding = onNavigateToLanding, + onNavigateToPasswordGuidance = onNavigateToPasswordGuidance, + onNavigateToPreventAccountLockout = onNavigateToPreventAccountLockout, + onNavigateToLogin = onNavigateToLogin, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt index 90acbab82..309b6fbf0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt @@ -1,7 +1,10 @@ package com.x8bit.bitwarden.ui.auth.feature.completeregistration +import android.content.res.Configuration import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -9,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,30 +23,30 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.handlers.CompleteRegistrationHandler +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.handlers.rememberCompleteRegistrationHandler import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar -import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog @@ -50,10 +54,12 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter -import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.platform.theme.nonMaterialTypography +import com.x8bit.bitwarden.ui.platform.util.isPortrait /** * Top level composable for the complete registration screen. @@ -63,11 +69,13 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @Composable fun CompleteRegistrationScreen( onNavigateBack: () -> Unit, - onNavigateToLanding: () -> Unit, - intentManager: IntentManager = LocalIntentManager.current, + onNavigateToPasswordGuidance: () -> Unit, + onNavigateToPreventAccountLockout: () -> Unit, + onNavigateToLogin: (email: String, token: String) -> Unit, viewModel: CompleteRegistrationViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handler = rememberCompleteRegistrationHandler(viewModel = viewModel) val context = LocalContext.current EventsEffect(viewModel) { event -> when (event) { @@ -76,8 +84,16 @@ fun CompleteRegistrationScreen( Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() } - is CompleteRegistrationEvent.NavigateToLanding -> { - onNavigateToLanding() + CompleteRegistrationEvent.NavigateToMakePasswordStrong -> onNavigateToPasswordGuidance() + CompleteRegistrationEvent.NavigateToPreventAccountLockout -> { + onNavigateToPreventAccountLockout() + } + + is CompleteRegistrationEvent.NavigateToLogin -> { + onNavigateToLogin( + event.email, + event.captchaToken, + ) } } } @@ -87,9 +103,7 @@ fun CompleteRegistrationScreen( is CompleteRegistrationDialog.Error -> { BitwardenBasicDialog( visibilityState = dialog.state, - onDismissRequest = remember(viewModel) { - { viewModel.trySendAction(ErrorDialogDismiss) } - }, + onDismissRequest = handler.onDismissErrorDialog, ) } @@ -99,15 +113,9 @@ fun CompleteRegistrationScreen( message = dialog.message(), confirmButtonText = stringResource(id = R.string.yes), dismissButtonText = stringResource(id = R.string.no), - onConfirmClick = remember(viewModel) { - { viewModel.trySendAction(ContinueWithBreachedPasswordClick) } - }, - onDismissClick = remember(viewModel) { - { viewModel.trySendAction(ErrorDialogDismiss) } - }, - onDismissRequest = remember(viewModel) { - { viewModel.trySendAction(ErrorDialogDismiss) } - }, + onConfirmClick = handler.onContinueWithBreachedPasswordClick, + onDismissClick = handler.onDismissErrorDialog, + onDismissRequest = handler.onDismissErrorDialog, ) } @@ -127,22 +135,11 @@ fun CompleteRegistrationScreen( .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = stringResource(id = R.string.set_password), + title = stringResource(id = R.string.create_account), scrollBehavior = scrollBehavior, - navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), - navigationIconContentDescription = stringResource(id = R.string.close), - onNavigationIconClick = remember(viewModel) { - { viewModel.trySendAction(CloseClick) } - }, - actions = { - BitwardenTextButton( - label = stringResource(id = R.string.create_account), - onClick = remember(viewModel) { - { viewModel.trySendAction(CreateAccountClick) } - }, - modifier = Modifier.testTag("CreateAccountButton"), - ) - }, + navigationIcon = rememberVectorPainter(id = R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = handler.onBackClick, ) }, ) { innerPadding -> @@ -153,84 +150,198 @@ fun CompleteRegistrationScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - Spacer(modifier = Modifier.height(16.dp)) - - @Suppress("MaxLineLength") - Text( - text = stringResource( - id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account, - state.userEmail, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(16.dp)) - var showPassword by rememberSaveable { mutableStateOf(false) } - BitwardenPasswordField( - label = stringResource(id = R.string.master_password), - showPassword = showPassword, - showPasswordChange = { showPassword = it }, - value = state.passwordInput, - hint = state.passwordLengthLabel(), - onValueChange = remember(viewModel) { - { viewModel.trySendAction(PasswordInputChange(it)) } - }, - modifier = Modifier - .testTag("MasterPasswordEntry") - .fillMaxWidth() - .padding(horizontal = 16.dp), - showPasswordTestTag = "PasswordVisibilityToggle", - ) - Spacer(modifier = Modifier.height(8.dp)) - PasswordStrengthIndicator( - modifier = Modifier.padding(horizontal = 16.dp), - state = state.passwordStrengthState, - ) - Spacer(modifier = Modifier.height(8.dp)) - BitwardenPasswordField( - label = stringResource(id = R.string.retype_master_password), - value = state.confirmPasswordInput, - showPassword = showPassword, - showPasswordChange = { showPassword = it }, - onValueChange = remember(viewModel) { - { viewModel.trySendAction(ConfirmPasswordInputChange(it)) } - }, - modifier = Modifier - .testTag("ConfirmMasterPasswordEntry") - .fillMaxWidth() - .padding(horizontal = 16.dp), - showPasswordTestTag = "ConfirmPasswordVisibilityToggle", - ) - Spacer(modifier = Modifier.height(16.dp)) - BitwardenTextField( - label = stringResource(id = R.string.master_password_hint), - value = state.passwordHintInput, - onValueChange = remember(viewModel) { - { viewModel.trySendAction(PasswordHintChange(it)) } - }, - hint = stringResource(id = R.string.master_password_hint_description), - modifier = Modifier - .testTag("MasterPasswordHintLabel") - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(24.dp)) - BitwardenSwitch( - label = stringResource(id = R.string.check_known_data_breaches_for_this_password), - isChecked = state.isCheckDataBreachesToggled, - onCheckedChange = remember(viewModel) { - { newState -> - viewModel.trySendAction(CheckDataBreachesToggle(newState = newState)) - } - }, - modifier = Modifier - .testTag("CheckExposedMasterPasswordToggle") - .padding(horizontal = 16.dp), + CompleteRegistrationContent( + passwordInput = state.passwordInput, + passwordStrengthState = state.passwordStrengthState, + confirmPasswordInput = state.confirmPasswordInput, + passwordHintInput = state.passwordHintInput, + isCheckDataBreachesToggled = state.isCheckDataBreachesToggled, + handler = handler, + modifier = Modifier.standardHorizontalMargin(), + nextButtonEnabled = state.hasValidMasterPassword, + callToActionText = state.callToActionText(), ) Spacer(modifier = Modifier.navigationBarsPadding()) } } } + +@Suppress("LongMethod") +@Composable +private fun CompleteRegistrationContent( + passwordInput: String, + passwordStrengthState: PasswordStrengthState, + confirmPasswordInput: String, + passwordHintInput: String, + isCheckDataBreachesToggled: Boolean, + nextButtonEnabled: Boolean, + callToActionText: String, + handler: CompleteRegistrationHandler, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth(), + ) { + Spacer(modifier = Modifier.height(8.dp)) + CompleteRegistrationContentHeader( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip), + actionText = stringResource(id = R.string.what_makes_a_password_strong), + callToActionText = stringResource(id = R.string.learn_more), + onCardClicked = handler.onMakeStrongPassword, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + + var showPassword by rememberSaveable { mutableStateOf(false) } + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + showPassword = showPassword, + showPasswordChange = { showPassword = it }, + value = passwordInput, + onValueChange = handler.onPasswordInputChange, + modifier = Modifier + .testTag("MasterPasswordEntry") + .fillMaxWidth(), + showPasswordTestTag = "PasswordVisibilityToggle", + imeAction = ImeAction.Next, + ) + Spacer(modifier = Modifier.height(8.dp)) + PasswordStrengthIndicator( + modifier = Modifier.padding(horizontal = 16.dp), + state = passwordStrengthState, + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenPasswordField( + label = stringResource(id = R.string.retype_master_password), + value = confirmPasswordInput, + showPassword = showPassword, + showPasswordChange = { showPassword = it }, + onValueChange = handler.onConfirmPasswordInputChange, + modifier = Modifier + .testTag("ConfirmMasterPasswordEntry") + .fillMaxWidth(), + showPasswordTestTag = "ConfirmPasswordVisibilityToggle", + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextField( + label = stringResource(id = R.string.master_password_hint), + value = passwordHintInput, + onValueChange = handler.onPasswordHintChange, + hint = stringResource( + R.string.bitwarden_cannot_recover_a_lost_or_forgotten_master_password, + ), + modifier = Modifier + .testTag("MasterPasswordHintLabel") + .fillMaxWidth(), + ) + BitwardenClickableText( + label = stringResource(id = R.string.learn_about_other_ways_to_prevent_account_lockout), + onClick = handler.onLearnToPreventLockout, + style = nonMaterialTypography.labelMediumProminent, + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenSwitch( + label = stringResource(id = R.string.check_known_data_breaches_for_this_password), + isChecked = isCheckDataBreachesToggled, + onCheckedChange = handler.onCheckDataBreachesToggle, + modifier = Modifier.testTag("CheckExposedMasterPasswordToggle"), + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenFilledButton( + label = callToActionText, + isEnabled = nextButtonEnabled, + onClick = handler.onCallToAction, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun CompleteRegistrationContentHeader( + modifier: Modifier = Modifier, + configuration: Configuration = LocalConfiguration.current, +) { + + if (configuration.isPortrait) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OrderedHeaderContent() + } + } else { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + OrderedHeaderContent() + } + } +} + +/** + * Header content ordered with the image "first" and the text "second" which can be placed in a + * [Column] or [Row]. + */ +@Composable +private fun OrderedHeaderContent() { + Image( + painter = rememberVectorPainter(id = R.drawable.lock), + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Spacer(modifier = Modifier.size(24.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.choose_your_master_password), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource( + R.string.choose_a_unique_and_strong_password_to_keep_your_information_safe, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } +} + +@PreviewScreenSizes +@Composable +private fun CompleteRegistrationContentPreview() { + BitwardenTheme { + CompleteRegistrationContent( + passwordInput = "tortor", + passwordStrengthState = PasswordStrengthState.WEAK_3, + confirmPasswordInput = "consequat", + passwordHintInput = "dissentiunt", + isCheckDataBreachesToggled = false, + handler = CompleteRegistrationHandler( + onDismissErrorDialog = {}, + onContinueWithBreachedPasswordClick = {}, + onBackClick = {}, + onPasswordInputChange = {}, + onConfirmPasswordInputChange = {}, + onPasswordHintChange = {}, + onCheckDataBreachesToggle = {}, + onLearnToPreventLockout = {}, + onMakeStrongPassword = {}, + onCallToAction = {}, + ), + callToActionText = "Next", + nextButtonEnabled = true, + modifier = Modifier.standardHorizontalMargin(), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt index f1024702a..f925ed212 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt @@ -8,13 +8,14 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.BackClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult @@ -23,7 +24,6 @@ import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistra import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText -import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import dagger.hilt.android.lifecycle.HiltViewModel @@ -40,12 +40,13 @@ private const val KEY_STATE = "state" private const val MIN_PASSWORD_LENGTH = 12 /** - * Models logic for the create account screen. + * Models logic for the Complete Registration screen. */ @Suppress("TooManyFunctions") @HiltViewModel class CompleteRegistrationViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + featureFlagManager: FeatureFlagManager, private val authRepository: AuthRepository, private val environmentRepository: EnvironmentRepository, private val specialCircumstanceManager: SpecialCircumstanceManager, @@ -63,6 +64,7 @@ class CompleteRegistrationViewModel @Inject constructor( isCheckDataBreachesToggled = true, dialog = null, passwordStrengthState = PasswordStrengthState.NONE, + onBoardingEnabled = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow), ) }, ) { @@ -90,11 +92,10 @@ class CompleteRegistrationViewModel @Inject constructor( override fun handleAction(action: CompleteRegistrationAction) { when (action) { - is CreateAccountClick -> handleCreateAccountClick() is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action) is PasswordHintChange -> handlePasswordHintChanged(action) is PasswordInputChange -> handlePasswordInputChanged(action) - is CloseClick -> handleCloseClick() + is BackClick -> handleBackClicked() is ErrorDialogDismiss -> handleDialogDismiss() is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action) is Internal.ReceiveRegisterResult -> { @@ -103,6 +104,15 @@ class CompleteRegistrationViewModel @Inject constructor( ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action) + CompleteRegistrationAction.LearnToPreventLockoutClick -> { + handlePreventAccountLockoutClickAction() + } + + CompleteRegistrationAction.MakePasswordStrongClick -> { + handleMakePasswordStrongClickAction() + } + + CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick() } } @@ -170,7 +180,10 @@ class CompleteRegistrationViewModel @Inject constructor( is RegisterResult.Success -> { mutableStateFlow.update { it.copy(dialog = null) } sendEvent( - CompleteRegistrationEvent.NavigateToLanding, + CompleteRegistrationEvent.NavigateToLogin( + email = state.userEmail, + captchaToken = registerAccountResult.captchaToken, + ), ) } @@ -221,7 +234,7 @@ class CompleteRegistrationViewModel @Inject constructor( } } - private fun handleCloseClick() { + private fun handleBackClicked() { sendEvent(CompleteRegistrationEvent.NavigateBack) } @@ -253,41 +266,14 @@ class CompleteRegistrationViewModel @Inject constructor( mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) } } - private fun handleCreateAccountClick() = when { - state.userEmail.isBlank() -> { - val dialog = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.validation_field_required - .asText(R.string.email_address.asText()), - ) - mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } - } - - !state.userEmail.isValidEmail() -> { + private fun handleCallToActionClick() { + if (!state.userEmail.isValidEmail()) { val dialog = BasicDialogState.Shown( title = R.string.an_error_has_occurred.asText(), message = R.string.invalid_email.asText(), ) mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } - } - - state.passwordInput.length < MIN_PASSWORD_LENGTH -> { - val dialog = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH), - ) - mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } - } - - state.passwordInput != state.confirmPasswordInput -> { - val dialog = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_confirmation_val_message.asText(), - ) - mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } - } - - else -> { + } else { submitRegisterAccountRequest( shouldCheckForDataBreaches = state.isCheckDataBreachesToggled, shouldIgnorePasswordStrength = false, @@ -295,6 +281,14 @@ class CompleteRegistrationViewModel @Inject constructor( } } + private fun handleMakePasswordStrongClickAction() { + sendEvent(CompleteRegistrationEvent.NavigateToMakePasswordStrong) + } + + private fun handlePreventAccountLockoutClickAction() { + sendEvent(CompleteRegistrationEvent.NavigateToPreventAccountLockout) + } + private fun handleContinueWithBreachedPasswordClick() { submitRegisterAccountRequest( shouldCheckForDataBreaches = false, @@ -345,19 +339,18 @@ data class CompleteRegistrationState( val isCheckDataBreachesToggled: Boolean, val dialog: CompleteRegistrationDialog?, val passwordStrengthState: PasswordStrengthState, + val onBoardingEnabled: Boolean, ) : Parcelable { - val passwordLengthLabel: Text - // Have to concat a few strings here, resulting string is: - // Important: Your master password cannot be recovered if you forget it! 12 - // characters minimum - @Suppress("MaxLineLength") - get() = R.string.important.asText() - .concat( - ": ".asText(), - R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum - .asText(MIN_PASSWORD_LENGTH), - ) + /** + * The text to display on the call to action button. + */ + val callToActionText: Text + get() = if (onBoardingEnabled) { + R.string.next.asText() + } else { + R.string.create_account.asText() + } /** * Whether or not the provided master password is considered strong. @@ -374,6 +367,14 @@ data class CompleteRegistrationState( PasswordStrengthState.STRONG, -> true } + + /** + * Whether the form is valid. + */ + val hasValidMasterPassword: Boolean + get() = passwordInput == confirmPasswordInput && + passwordInput.isNotBlank() && + passwordInput.length >= MIN_PASSWORD_LENGTH } /** @@ -423,24 +424,33 @@ sealed class CompleteRegistrationEvent { ) : CompleteRegistrationEvent() /** - * Navigates to the landing screen. + * Navigates to prevent account lockout info screen */ - data object NavigateToLanding : CompleteRegistrationEvent() + data object NavigateToPreventAccountLockout : CompleteRegistrationEvent() + + /** + * Navigates to make password strong screen + */ + data object NavigateToMakePasswordStrong : CompleteRegistrationEvent() + + /** + * Navigates to the captcha verification screen. + */ + data class NavigateToLogin( + val email: String, + val captchaToken: String, + ) : CompleteRegistrationEvent() } /** * Models actions for the complete registration screen. */ sealed class CompleteRegistrationAction { - /** - * User clicked create account. - */ - data object CreateAccountClick : CompleteRegistrationAction() /** - * User clicked close. + * User clicked back. */ - data object CloseClick : CompleteRegistrationAction() + data object BackClick : CompleteRegistrationAction() /** * User clicked "Yes" when being asked if they are sure they want to use a breached password. @@ -472,6 +482,21 @@ sealed class CompleteRegistrationAction { */ data class CheckDataBreachesToggle(val newState: Boolean) : CompleteRegistrationAction() + /** + * User clicked on the make password strong card. + */ + data object MakePasswordStrongClick : CompleteRegistrationAction() + + /** + * User clicked on learn to prevent lockout text. + */ + data object LearnToPreventLockoutClick : CompleteRegistrationAction() + + /** + * User clicked on the "CTA" button. + */ + data object CallToActionClick : CompleteRegistrationAction() + /** * Models actions that the [CompleteRegistrationViewModel] itself might send. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/handlers/CompleteRegistrationHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/handlers/CompleteRegistrationHandler.kt new file mode 100644 index 000000000..2522f9a26 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/handlers/CompleteRegistrationHandler.kt @@ -0,0 +1,86 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationViewModel + +/** + * Handler for the complete registration screen lambda invocations. + */ +@Suppress("LongParameterList") +class CompleteRegistrationHandler( + val onDismissErrorDialog: () -> Unit, + val onContinueWithBreachedPasswordClick: () -> Unit, + val onBackClick: () -> Unit, + val onPasswordInputChange: (String) -> Unit, + val onConfirmPasswordInputChange: (String) -> Unit, + val onPasswordHintChange: (String) -> Unit, + val onCheckDataBreachesToggle: (Boolean) -> Unit, + val onMakeStrongPassword: () -> Unit, + val onLearnToPreventLockout: () -> Unit, + val onCallToAction: () -> Unit, +) { + companion object { + /** + * Create [CompleteRegistrationHandler] with the given [viewModel] to send actions to. + */ + fun create(viewModel: CompleteRegistrationViewModel) = CompleteRegistrationHandler( + onDismissErrorDialog = { + viewModel.trySendAction(CompleteRegistrationAction.ErrorDialogDismiss) + }, + onContinueWithBreachedPasswordClick = { + viewModel.trySendAction( + CompleteRegistrationAction.ContinueWithBreachedPasswordClick, + ) + }, + onBackClick = { viewModel.trySendAction(CompleteRegistrationAction.BackClick) }, + onPasswordInputChange = { + viewModel.trySendAction( + CompleteRegistrationAction.PasswordInputChange( + it, + ), + ) + }, + onConfirmPasswordInputChange = { + viewModel.trySendAction( + CompleteRegistrationAction.ConfirmPasswordInputChange( + it, + ), + ) + }, + onPasswordHintChange = { + viewModel.trySendAction( + CompleteRegistrationAction.PasswordHintChange( + it, + ), + ) + }, + onCheckDataBreachesToggle = { + viewModel.trySendAction( + CompleteRegistrationAction.CheckDataBreachesToggle( + it, + ), + ) + }, + onMakeStrongPassword = { + viewModel.trySendAction(CompleteRegistrationAction.MakePasswordStrongClick) + }, + onLearnToPreventLockout = { + viewModel.trySendAction(CompleteRegistrationAction.LearnToPreventLockoutClick) + }, + onCallToAction = { + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) + }, + ) + } +} + +/** + * Remember [CompleteRegistrationHandler] with the given [viewModel] within a [Composable] scope. + */ +@Composable +fun rememberCompleteRegistrationHandler(viewModel: CompleteRegistrationViewModel) = + remember(viewModel) { + CompleteRegistrationHandler.create(viewModel) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 5d6e5b4ed..e8a313284 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -473,7 +473,7 @@ sealed class CreateAccountEvent { data class NavigateToCaptcha(val uri: Uri) : CreateAccountEvent() /** - * Navigates to the captcha verification screen. + * Navigates to the login screen bypassing captcha with token. */ data class NavigateToLogin( val email: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt index e8913541a..1e55a0de6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt @@ -10,13 +10,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -38,7 +34,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @@ -89,7 +87,7 @@ fun MasterPasswordGuidanceScreen( .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(innerPadding) - .padding(horizontal = 16.dp), + .standardHorizontalMargin(), ) { Column( modifier = Modifier @@ -160,57 +158,26 @@ private fun TryGeneratorCard( onCardClicked: () -> Unit, modifier: Modifier = Modifier, ) { - Card( - onClick = onCardClicked, - shape = RoundedCornerShape(size = 16.dp), - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), + actionText = stringResource( + R.string.use_the_generator_to_create_a_strong_unique_password, ), - elevation = CardDefaults.elevatedCardElevation(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_generator), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(weight = 1f), - ) { - Text( - text = stringResource( - R.string.use_the_generator_to_create_a_strong_unique_password, - ), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.try_it_out), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(modifier = Modifier.width(16.dp)) + callToActionText = stringResource(R.string.try_it_out), + onCardClicked = onCardClicked, + modifier = modifier + .fillMaxWidth(), + trailingContent = { Icon( painter = rememberVectorPainter(id = R.drawable.ic_navigate_next), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .align(Alignment.CenterVertically) + .align(Alignment.Center) .size(16.dp), ) - } - } + }, + ) } @Composable diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt new file mode 100644 index 000000000..a0296cb19 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt @@ -0,0 +1,119 @@ +package com.x8bit.bitwarden.ui.platform.components.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A reusable card for displaying actions to the user. + */ +@Composable +fun BitwardenActionCard( + actionIcon: VectorPainter, + actionText: String, + callToActionText: String, + onCardClicked: () -> Unit, + modifier: Modifier = Modifier, + trailingContent: (@Composable BoxScope.() -> Unit)? = null, +) { + Card( + onClick = onCardClicked, + shape = RoundedCornerShape(size = 16.dp), + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + ), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Icon( + painter = actionIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(weight = 1f), + ) { + Text( + text = actionText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = callToActionText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + trailingContent?.invoke(this) + } + } + } +} + +@Preview +@Composable +private fun ActionCardPreview() { + BitwardenTheme { + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), + actionText = "This is an action.", + callToActionText = "Take action", + onCardClicked = { }, + ) + } +} + +@Preview +@Composable +private fun ActionCardWithTrailingPreview() { + BitwardenTheme { + BitwardenActionCard( + actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), + actionText = "An action with trailing content", + callToActionText = "Take action", + onCardClicked = {}, + trailingContent = { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } +} diff --git a/app/src/main/res/drawable-night/lock.xml b/app/src/main/res/drawable-night/lock.xml new file mode 100644 index 000000000..7a8422080 --- /dev/null +++ b/app/src/main/res/drawable-night/lock.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100644 index 000000000..b409a4304 --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f21e3176b..36a1ec9a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -982,4 +982,9 @@ Do you want to switch to this account? Email address (required) "Select the link in the email to verify your email address and continue creating your account. " Change email address + Next + Bitwarden cannot recover a lost or forgotten master password. + Choose your master password + Choose a unique and strong password to keep your information safe. + %1$s characters diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt index e28bb40bc..59fe6ab18 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt @@ -2,8 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.completeregistration import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText @@ -13,18 +17,16 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.BackClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -35,16 +37,14 @@ import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.robolectric.annotation.Config class CompleteRegistrationScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false - private var onNavigateToLandingCalled = false - - private val intentManager = mockk(relaxed = true) { - every { startCustomTabsActivity(any()) } just runs - every { startActivity(any()) } just runs - } + private var onNavigateToPreventAccountLockoutCalled = false + private var onNavigateToPasswordGuidanceCalled = false + private var onNavigateToLoginCalled = false private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow() @@ -59,23 +59,58 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { composeTestRule.setContent { CompleteRegistrationScreen( onNavigateBack = { onNavigateBackCalled = true }, - onNavigateToLanding = { onNavigateToLandingCalled = true }, - intentManager = intentManager, + onNavigateToPasswordGuidance = { onNavigateToPasswordGuidanceCalled = true }, + onNavigateToPreventAccountLockout = { + onNavigateToPreventAccountLockoutCalled = true + }, + onNavigateToLogin = { email, captchaToken -> + onNavigateToLoginCalled = true + assertTrue(email == EMAIL) + assertTrue(captchaToken == TOKEN) + }, viewModel = viewModel, ) } } @Test - fun `app bar submit click should send CreateAccountClick action`() { - composeTestRule.onNodeWithText("Create account").performClick() - verify { viewModel.trySendAction(CreateAccountClick) } + fun `call to action with valid input click should send CreateAccountClick action`() { + mutableStateFlow.update { + it.copy( + passwordInput = "password1234", + confirmPasswordInput = "password1234", + ) + } + composeTestRule + .onNode(hasText("Create account") and hasClickAction()) + .performScrollTo() + .assertIsEnabled() + .performClick() + verify { viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) } + } + + @Test + fun `call to action not enabled if passwords don't match`() { + mutableStateFlow.update { + it.copy( + passwordInput = "4321drowssap", + confirmPasswordInput = "password1234", + ) + } + composeTestRule + .onNode(hasText("Create account") and hasClickAction()) + .performScrollTo() + .assertIsNotEnabled() + .performClick() + verify(exactly = 0) { + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) + } } @Test fun `close click should send CloseClick action`() { - composeTestRule.onNodeWithContentDescription("Close").performClick() - verify { viewModel.trySendAction(CloseClick) } + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify { viewModel.trySendAction(BackClick) } } @Test @@ -93,12 +128,6 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } - @Test - fun `NavigateToLogin event should invoke navigate login lambda`() { - mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToLanding) - assertTrue(onNavigateToLandingCalled) - } - @Test fun `password input change should send PasswordInputChange action`() { composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT) @@ -182,7 +211,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { mutableStateFlow.update { DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_1) } - composeTestRule.onNodeWithText("Weak").assertIsDisplayed() + composeTestRule.onNodeWithText("Weak").performScrollTo().assertIsDisplayed() mutableStateFlow.update { DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_2) @@ -211,6 +240,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { composeTestRule .onAllNodesWithContentDescription("Show") .assertCountEquals(2)[0] + .performScrollTo() .performClick() // after clicking there should be no Show buttons: @@ -222,6 +252,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { composeTestRule .onAllNodesWithContentDescription("Hide") .assertCountEquals(2)[1] + .performScrollTo() .performClick() // then there should be two show buttons again @@ -230,6 +261,80 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { .assertCountEquals(2) } + @Test + fun `Click on action card should send MakePasswordStrongClick action`() { + composeTestRule + .onNodeWithText("Learn more") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(CompleteRegistrationAction.MakePasswordStrongClick) } + } + + @Test + fun `Click on prevent account lockout should send LearnToPreventLockoutClick action`() { + composeTestRule + .onNodeWithText("Learn about other ways to prevent account lockout") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction(CompleteRegistrationAction.LearnToPreventLockoutClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `NavigateToPreventAccountLockout event should invoke navigate to prevent account lockout lambda`() { + mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToPreventAccountLockout) + assertTrue(onNavigateToPreventAccountLockoutCalled) + } + + @Test + fun `NavigateToPasswordGuidance event should invoke navigate to password guidance lambda`() { + mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToMakePasswordStrong) + assertTrue(onNavigateToPasswordGuidanceCalled) + } + + @Test + fun `Header should be displayed in portrait mode`() { + composeTestRule + .onNodeWithText("Choose your master password") + .performScrollTo() + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Choose a unique and strong password to keep your information safe.") + .performScrollTo() + .assertIsDisplayed() + } + + @Config(qualifiers = "land") + @Test + fun `Header should be displayed in landscape mode`() { + composeTestRule + .onNodeWithText("Choose your master password") + .performScrollTo() + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Choose a unique and strong password to keep your information safe.") + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun `NavigateToLogin event should invoke navigate to login lambda`() { + mutableEventFlow.tryEmit( + CompleteRegistrationEvent.NavigateToLogin( + email = EMAIL, + captchaToken = TOKEN, + ), + ) + + assertTrue(onNavigateToLoginCalled) + } + companion object { private const val EMAIL = "test@test.com" private const val TOKEN = "token" @@ -244,6 +349,7 @@ class CompleteRegistrationScreenTest : BaseComposeTest() { isCheckDataBreachesToggled = true, dialog = null, passwordStrengthState = PasswordStrengthState.NONE, + onBoardingEnabled = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt index 15ebb452c..617c1a313 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt @@ -13,12 +13,14 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository -import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.BackClick import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange @@ -28,12 +30,14 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -52,7 +56,9 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { private val specialCircumstanceManager: SpecialCircumstanceManager = SpecialCircumstanceManagerImpl() - private var viewmodelVerifyEmailCalled = false + private val featureFlagManager = mockk(relaxed = true) { + every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false + } @BeforeEach fun setUp() { @@ -86,13 +92,14 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, specialCircumstanceManager = specialCircumstanceManager, + featureFlagManager = featureFlagManager, ) viewModel.onCleared() assertTrue(specialCircumstanceManager.specialCircumstance == null) } @Test - fun `CreateAccountClick with password below 12 chars should show password length dialog`() = + fun `Password below 12 chars should have non-valid state`() = runTest { val input = "abcdefghikl" coEvery { @@ -100,47 +107,24 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } returns PasswordStrengthResult.Error val viewModel = createCompleteRegistrationViewModel() viewModel.trySendAction(PasswordInputChange(input)) - val expectedState = DEFAULT_STATE.copy( - passwordInput = input, - dialog = CompleteRegistrationDialog.Error( - BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_length_val_message_x.asText(12), - ), - ), - ) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) - viewModel.stateFlow.test { - assertEquals(expectedState, awaitItem()) - } + + assertFalse(viewModel.stateFlow.value.hasValidMasterPassword) } @Test - fun `CreateAccountClick with passwords not matching should show password match dialog`() = + fun `Passwords not matching should have non-valid state`() = runTest { coEvery { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) } returns PasswordStrengthResult.Error val viewModel = createCompleteRegistrationViewModel() viewModel.trySendAction(PasswordInputChange(PASSWORD)) - val expectedState = DEFAULT_STATE.copy( - userEmail = EMAIL, - passwordInput = PASSWORD, - dialog = CompleteRegistrationDialog.Error( - BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_confirmation_val_message.asText(), - ), - ), - ) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) - viewModel.stateFlow.test { - assertEquals(expectedState, awaitItem()) - } + + assertFalse(viewModel.stateFlow.value.hasValidMasterPassword) } @Test - fun `CreateAccountClick with all inputs valid should show and hide loading dialog`() = runTest { + fun `CallToActionClick with all inputs valid should show and hide loading dialog`() = runTest { val repo = mockk { coEvery { register( @@ -159,13 +143,16 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { val stateFlow = viewModel.stateFlow.testIn(backgroundScope) val eventFlow = viewModel.eventFlow.testIn(backgroundScope) assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) assertEquals( VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading), stateFlow.awaitItem(), ) assertEquals( - CompleteRegistrationEvent.NavigateToLanding, + CompleteRegistrationEvent.NavigateToLogin( + EMAIL, + CAPTCHA_BYPASS_TOKEN, + ), eventFlow.awaitItem(), ) // Make sure loading dialog is hidden: @@ -174,7 +161,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } @Test - fun `CreateAccountClick register returns error should update errorDialogState`() = runTest { + fun `CallToActionClick register returns error should update errorDialogState`() = runTest { val repo = mockk { coEvery { register( @@ -191,7 +178,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) viewModel.stateFlow.test { assertEquals(VALID_INPUT_STATE, awaitItem()) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) assertEquals( VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading), awaitItem(), @@ -211,7 +198,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } @Test - fun `CreateAccountClick register returns Success should emit NavigateToLogin`() = runTest { + fun `CallToActionClick register returns Success should emit NavigateToLogin`() = runTest { val repo = mockk { coEvery { register( @@ -227,9 +214,12 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) viewModel.eventFlow.test { - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) assertEquals( - CompleteRegistrationEvent.NavigateToLanding, + CompleteRegistrationEvent.NavigateToLogin( + EMAIL, + CAPTCHA_BYPASS_TOKEN, + ), awaitItem(), ) } @@ -266,7 +256,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } @Test - fun `CreateAccountClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() = + fun `CallToActionClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() = runTest { mockAuthRepository.apply { coEvery { @@ -287,7 +277,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { val viewModel = createCompleteRegistrationViewModel( completeRegistrationState = initialState, ) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) viewModel.stateFlow.test { assertEquals( initialState.copy( @@ -304,7 +294,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") - fun `CreateAccountClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() = + fun `CallToActionClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() = runTest { mockAuthRepository.apply { coEvery { @@ -326,7 +316,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { val viewModel = createCompleteRegistrationViewModel(completeRegistrationState = initialState) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) viewModel.stateFlow.test { assertEquals( initialState.copy(dialog = createHaveIBeenPwned()), @@ -337,7 +327,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") - fun `CreateAccountClick register returns WeakPassword should show HaveIBeenPwned dialog`() = + fun `CallToActionClick register returns WeakPassword should show HaveIBeenPwned dialog`() = runTest { mockAuthRepository.apply { coEvery { @@ -359,7 +349,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { ) val viewModel = createCompleteRegistrationViewModel(completeRegistrationState = initialState) - viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) viewModel.stateFlow.test { assertEquals( initialState.copy( @@ -377,7 +367,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { fun `CloseClick should emit NavigateBack`() = runTest { val viewModel = createCompleteRegistrationViewModel() viewModel.eventFlow.test { - viewModel.trySendAction(CloseClick) + viewModel.trySendAction(BackClick) assertEquals(CompleteRegistrationEvent.NavigateBack, awaitItem()) } } @@ -498,15 +488,39 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { } } + @Test + fun `handling LearnToPreventLockoutClick should emit NavigateToPreventAccountLockout`() = + runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(CompleteRegistrationAction.LearnToPreventLockoutClick) + assertEquals(CompleteRegistrationEvent.NavigateToPreventAccountLockout, awaitItem()) + } + } + + @Test + fun `handling MakePasswordStrongClick should emit NavigateToMakePasswordStrong`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(CompleteRegistrationAction.MakePasswordStrongClick) + assertEquals(CompleteRegistrationEvent.NavigateToMakePasswordStrong, awaitItem()) + } + } + private fun createCompleteRegistrationViewModel( completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE, authRepository: AuthRepository = mockAuthRepository, ): CompleteRegistrationViewModel = CompleteRegistrationViewModel( - savedStateHandle = SavedStateHandle(mapOf("state" to completeRegistrationState)), + savedStateHandle = SavedStateHandle( + mapOf( + "state" to completeRegistrationState, + ), + ), authRepository = authRepository, environmentRepository = fakeEnvironmentRepository, specialCircumstanceManager = specialCircumstanceManager, + featureFlagManager = featureFlagManager, ) companion object { @@ -524,6 +538,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { isCheckDataBreachesToggled = true, dialog = null, passwordStrengthState = PasswordStrengthState.NONE, + onBoardingEnabled = false, ) private val VALID_INPUT_STATE = CompleteRegistrationState( userEmail = EMAIL, @@ -535,6 +550,7 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { isCheckDataBreachesToggled = false, dialog = null, passwordStrengthState = PasswordStrengthState.GOOD, + onBoardingEnabled = false, ) } }