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 abc412239..700e39e7b 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 @@ -29,6 +29,8 @@ import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreve import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.preventAccountLockoutDestination import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination +import com.x8bit.bitwarden.ui.auth.feature.startregistration.navigateToStartRegistration +import com.x8bit.bitwarden.ui.auth.feature.startregistration.startRegistrationDestination import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination import com.x8bit.bitwarden.ui.auth.feature.welcome.welcomeDestination @@ -58,6 +60,16 @@ fun NavGraphBuilder.authGraph( ) }, ) + startRegistrationDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToCompleteRegistration = { emailAddress, verificationToken -> + // TODO PR-3622 ADD NAVIGATION TO COMPLETE REGISTRATION + }, + onNavigateToCheckEmail = { emailAddress -> + // TODO PR-3621 ADD NAVIGATION TO CHECK EMAIL + }, + onNavigateToEnvironment = { navController.navigateToEnvironment() }, + ) enterpriseSignOnDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToSetPassword = { navController.navigateToSetPassword() }, @@ -80,6 +92,7 @@ fun NavGraphBuilder.authGraph( onNavigateToEnvironment = { navController.navigateToEnvironment() }, + onNavigateToStartRegistration = { navController.navigateToStartRegistration() }, ) welcomeDestination( onNavigateToCreateAccount = { navController.navigateToCreateAccount() }, 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 342a3445f..9964d5e1e 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 @@ -21,6 +21,7 @@ fun NavGraphBuilder.landingDestination( onNavigateToCreateAccount: () -> Unit, onNavigateToLogin: (emailAddress: String) -> Unit, onNavigateToEnvironment: () -> Unit, + onNavigateToStartRegistration: () -> Unit, ) { composableWithStayTransitions( route = LANDING_ROUTE, @@ -29,6 +30,7 @@ fun NavGraphBuilder.landingDestination( onNavigateToCreateAccount = onNavigateToCreateAccount, onNavigateToLogin = onNavigateToLogin, onNavigateToEnvironment = onNavigateToEnvironment, + onNavigateToStartRegistration = onNavigateToStartRegistration, ) } } 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 62c875f0f..cbb8f2b69 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 @@ -1,10 +1,7 @@ package com.x8bit.bitwarden.ui.auth.feature.landing import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,11 +14,8 @@ import androidx.compose.foundation.layout.padding 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.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -34,7 +28,6 @@ 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.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag @@ -55,14 +48,12 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog -import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog -import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow +import com.x8bit.bitwarden.ui.platform.components.dropdown.EnvironmentSelector 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.toggle.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter -import com.x8bit.bitwarden.ui.platform.util.displayLabel import kotlinx.collections.immutable.toImmutableList /** @@ -75,6 +66,7 @@ fun LandingScreen( onNavigateToCreateAccount: () -> Unit, onNavigateToLogin: (emailAddress: String) -> Unit, onNavigateToEnvironment: () -> Unit, + onNavigateToStartRegistration: () -> Unit, viewModel: LandingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -86,6 +78,7 @@ fun LandingScreen( ) LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment() + LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration() } } @@ -268,6 +261,7 @@ private fun LandingScreenContent( Spacer(modifier = Modifier.height(2.dp)) EnvironmentSelector( + labelText = stringResource(id = R.string.logging_in_on), selectedOption = state.selectedEnvironmentType, onOptionSelected = onEnvironmentTypeSelect, modifier = Modifier @@ -326,82 +320,3 @@ private fun LandingScreenContent( Spacer(modifier = Modifier.navigationBarsPadding()) } } - -/** - * A dropdown selector UI component specific to region url selection on the Landing screen. - * - * This composable displays a dropdown menu allowing users to select a region - * from a list of options. When an option is selected, it invokes the provided callback - * and displays the currently selected region on the UI. - * - * @param selectedOption The currently selected environment option. - * @param onOptionSelected A callback that gets invoked when an environment option is selected - * and passes the selected option as an argument. - * @param modifier A [Modifier] for the composable. - * - */ -@Composable -private fun EnvironmentSelector( - selectedOption: Environment.Type, - onOptionSelected: (Environment.Type) -> Unit, - modifier: Modifier = Modifier, -) { - val options = Environment.Type.entries.toTypedArray() - var shouldShowDialog by rememberSaveable { mutableStateOf(false) } - - Box(modifier = modifier) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(28.dp)) - .clickable( - indication = rememberRipple( - bounded = true, - color = MaterialTheme.colorScheme.primary, - ), - interactionSource = remember { MutableInteractionSource() }, - onClick = { shouldShowDialog = !shouldShowDialog }, - ) - .padding( - vertical = 8.dp, - horizontal = 16.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.logging_in_on), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 12.dp), - ) - Text( - text = selectedOption.displayLabel(), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 8.dp), - ) - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_region_select_dropdown), - contentDescription = stringResource(id = R.string.region), - tint = MaterialTheme.colorScheme.primary, - ) - } - - if (shouldShowDialog) { - BitwardenSelectionDialog( - title = stringResource(id = R.string.logging_in_on), - onDismissRequest = { shouldShowDialog = false }, - ) { - options.forEach { - BitwardenSelectionRow( - text = it.displayLabel, - onClick = { - onOptionSelected.invoke(it) - shouldShowDialog = false - }, - isSelected = it == selectedOption, - ) - } - } - } - } -} 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 cf48511b5..031c157ce 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 @@ -191,6 +191,7 @@ class LandingViewModel @Inject constructor( } private fun handleCreateAccountClicked() { + // TODO PM-9401: ADD FEATURE FLAG email-verification and navigation to StartRegistration sendEvent(LandingEvent.NavigateToCreateAccount) } @@ -291,6 +292,11 @@ sealed class LandingEvent { */ data object NavigateToCreateAccount : LandingEvent() + /** + * Navigates to the Start Registration screen. + */ + data object NavigateToStartRegistration : LandingEvent() + /** * Navigates to the Login screen with the given email address and region label. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt new file mode 100644 index 000000000..1e575ac50 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt @@ -0,0 +1,39 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val START_REGISTRATION_ROUTE = "start_registration" + +/** + * Navigate to the start registration screen. + */ +fun NavController.navigateToStartRegistration(navOptions: NavOptions? = null) { + this.navigate(START_REGISTRATION_ROUTE, navOptions) +} + +/** + * Add the start registration screen to the nav graph. + */ +fun NavGraphBuilder.startRegistrationDestination( + onNavigateBack: () -> Unit, + onNavigateToCompleteRegistration: ( + emailAddress: String, + verificationToken: String, + ) -> Unit, + onNavigateToCheckEmail: (email: String) -> Unit, + onNavigateToEnvironment: () -> Unit, +) { + composableWithSlideTransitions( + route = START_REGISTRATION_ROUTE, + ) { + StartRegistrationScreen( + onNavigateBack = onNavigateBack, + onNavigateToCompleteRegistration = onNavigateToCompleteRegistration, + onNavigateToCheckEmail = onNavigateToCheckEmail, + onNavigateToEnvironment = onNavigateToEnvironment, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt new file mode 100644 index 000000000..b094c0ea2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt @@ -0,0 +1,402 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ContinueClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.PrivacyPolicyClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ReceiveMarketingEmailsToggle +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.UnsubscribeMarketingEmailsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToPrivacyPolicy +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms +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.createAnnotatedString +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +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.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.dropdown.EnvironmentSelector +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.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager + +/** + * Constant string to be used in string annotation tag field + */ +private const val TAG_URL = "URL" + +/** + * Top level composable for the start registration screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun StartRegistrationScreen( + onNavigateBack: () -> Unit, + onNavigateToCompleteRegistration: ( + emailAddress: String, + verificationToken: String, + ) -> Unit, + onNavigateToCheckEmail: (email: String) -> Unit, + onNavigateToEnvironment: () -> Unit, + intentManager: IntentManager = LocalIntentManager.current, + viewModel: StartRegistrationViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + EventsEffect(viewModel) { event -> + when (event) { + is NavigateToPrivacyPolicy -> { + intentManager.launchUri("https://bitwarden.com/privacy/".toUri()) + } + + is NavigateToTerms -> { + intentManager.launchUri("https://bitwarden.com/terms/".toUri()) + } + + is StartRegistrationEvent.NavigateToUnsubscribe -> { + intentManager.launchUri("https://bitwarden.com/email-preferences/".toUri()) + } + + is StartRegistrationEvent.NavigateBack -> onNavigateBack.invoke() + is StartRegistrationEvent.ShowToast -> { + Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show() + } + + is StartRegistrationEvent.NavigateToCompleteRegistration -> { + onNavigateToCompleteRegistration( + event.email, + event.verificationToken, + ) + } + + is StartRegistrationEvent.NavigateToCheckEmail -> { + onNavigateToCheckEmail( + event.email, + ) + } + + StartRegistrationEvent.NavigateToEnvironment -> onNavigateToEnvironment() + } + } + + // Show dialog if needed: + when (val dialog = state.dialog) { + is StartRegistrationDialog.Error -> { + BitwardenBasicDialog( + visibilityState = dialog.state, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + ) + } + + StartRegistrationDialog.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()), + ) + } + + null -> Unit + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + 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) } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextField( + label = stringResource(id = R.string.email_address), + value = state.emailInput, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EmailInputChange(it)) } + }, + modifier = Modifier + .testTag("EmailAddressEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + keyboardType = KeyboardType.Email, + ) + Spacer(modifier = Modifier.height(2.dp)) + EnvironmentSelector( + labelText = stringResource(id = R.string.creating_on), + selectedOption = state.selectedEnvironmentType, + onOptionSelected = remember(viewModel) { + { viewModel.trySendAction(EnvironmentTypeSelect(it)) } + }, + modifier = Modifier + .testTag("RegionSelectorDropdown") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = state.nameInput, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(NameInputChange(it)) } + }, + modifier = Modifier + .testTag("NameEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (state.selectedEnvironmentType != Environment.Type.SELF_HOSTED) { + ReceiveMarketingEmailsSwitch( + isChecked = state.isReceiveMarketingEmailsToggled, + onCheckedChange = remember(viewModel) { + { + viewModel.trySendAction( + ReceiveMarketingEmailsToggle( + it, + ), + ) + } + }, + onUnsubscribeClick = remember(viewModel) { + { viewModel.trySendAction(UnsubscribeMarketingEmailsClick) } + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + BitwardenFilledButton( + label = stringResource(id = R.string.continue_text), + onClick = remember(viewModel) { + { viewModel.trySendAction(ContinueClick) } + }, + isEnabled = state.isContinueButtonEnabled, + modifier = Modifier + .testTag("ContinueButton") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyText( + onTermsClick = remember(viewModel) { + { viewModel.trySendAction(TermsClick) } + }, + onPrivacyPolicyClick = remember(viewModel) { + { viewModel.trySendAction(PrivacyPolicyClick) } + }, + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +private fun TermsAndPrivacyText( + onTermsClick: () -> Unit, + onPrivacyPolicyClick: () -> Unit, +) { + val annotatedLinkString: AnnotatedString = buildAnnotatedString { + val strTermsAndPrivacy = stringResource( + id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy, + ) + val strTerms = stringResource(id = R.string.terms_of_service) + val strPrivacy = stringResource(id = R.string.privacy_policy) + val startIndexTerms = strTermsAndPrivacy.indexOf(strTerms) + val endIndexTerms = startIndexTerms + strTerms.length + val startIndexPrivacy = strTermsAndPrivacy.indexOf(strPrivacy) + val endIndexPrivacy = startIndexPrivacy + strPrivacy.length + append(strTermsAndPrivacy) + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + ), + start = 0, + end = strTermsAndPrivacy.length, + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ), + start = startIndexTerms, + end = endIndexTerms, + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ), + start = startIndexPrivacy, + end = endIndexPrivacy, + ) + addStringAnnotation( + tag = TAG_URL, + annotation = strTerms, + start = startIndexTerms, + end = endIndexTerms, + ) + addStringAnnotation( + tag = TAG_URL, + annotation = strPrivacy, + start = startIndexPrivacy, + end = endIndexPrivacy, + ) + } + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .semantics(mergeDescendants = true) { + testTag = "DisclaimerText" + } + .fillMaxWidth(), + ) { + val termsUrl = stringResource(id = R.string.terms_of_service) + Column(Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)) { + ClickableText( + text = annotatedLinkString, + style = MaterialTheme.typography.bodyMedium, + onClick = { + annotatedLinkString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { stringAnnotation -> + if (stringAnnotation.item == termsUrl) { + onTermsClick() + } else { + onPrivacyPolicyClick() + } + } + }, + ) + } + } +} + +@Suppress("LongMethod") +@Composable +private fun ReceiveMarketingEmailsSwitch( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + onUnsubscribeClick: () -> Unit, +) { + @Suppress("MaxLineLength") + val annotatedLinkString = createAnnotatedString( + mainString = stringResource(id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time), + highlights = listOf(stringResource(id = R.string.unsubscribe)), + tag = TAG_URL, + ) + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .semantics(mergeDescendants = true) { + testTag = "ReceiveMarketingEmailsToggle" + toggleableState = ToggleableState(isChecked) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = { onCheckedChange.invoke(!isChecked) }, + ) + .padding(start = 16.dp) + .fillMaxWidth(), + ) { + Switch( + modifier = Modifier + .height(32.dp) + .width(52.dp), + checked = isChecked, + onCheckedChange = null, + ) + Column(Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)) { + ClickableText( + text = annotatedLinkString, + style = MaterialTheme.typography.bodyMedium, + onClick = { + annotatedLinkString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { + onUnsubscribeClick() + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt new file mode 100644 index 000000000..66f0c53cc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt @@ -0,0 +1,409 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.model.Environment.Type +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ContinueClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ReceiveMarketingEmailsToggle +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.PrivacyPolicyClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.UnsubscribeMarketingEmailsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.UpdatedEnvironmentReceive +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail +import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Models logic for the start registration screen. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class StartRegistrationViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, + private val environmentRepository: EnvironmentRepository, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: StartRegistrationState( + emailInput = "", + nameInput = "", + isReceiveMarketingEmailsToggled = environmentRepository.environment.type == Type.US, + isContinueButtonEnabled = false, + selectedEnvironmentType = environmentRepository.environment.type, + dialog = null, + ), +) { + + init { + // As state updates, write to saved state handle: + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + + // Listen for changes in environment triggered both by this VM and externally. + environmentRepository + .environmentStateFlow + .onEach { environment -> + sendAction( + UpdatedEnvironmentReceive(environment = environment), + ) + } + .launchIn(viewModelScope) + } + + override fun handleAction(action: StartRegistrationAction) { + when (action) { + is ContinueClick -> handleContinueClick() + is EmailInputChange -> handleEmailInputChanged(action) + is NameInputChange -> handleNameInputChanged(action) + is CloseClick -> handleCloseClick() + is ErrorDialogDismiss -> handleDialogDismiss() + is ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle( + action, + ) + + is PrivacyPolicyClick -> handlePrivacyPolicyClick() + is TermsClick -> handleTermsClick() + is UnsubscribeMarketingEmailsClick -> handleUnsubscribeMarketingEmailsClick() + is ReceiveSendVerificationEmailResult -> { + handleReceiveSendVerificationEmailResult(action) + } + + is EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action) + is UpdatedEnvironmentReceive -> { + handleUpdatedEnvironmentReceive(action) + } + } + } + + private fun handleEnvironmentTypeSelect(action: EnvironmentTypeSelect) { + val environment = when (action.environmentType) { + Type.US -> Environment.Us + Type.EU -> Environment.Eu + Type.SELF_HOSTED -> { + // Launch the self-hosted screen and select the full environment details there. + sendEvent(StartRegistrationEvent.NavigateToEnvironment) + return + } + } + + // Update the environment in the repo; the VM state will update accordingly because it is + // listening for changes. + environmentRepository.environment = environment + } + + private fun handleUpdatedEnvironmentReceive( + action: UpdatedEnvironmentReceive, + ) { + mutableStateFlow.update { + it.copy( + selectedEnvironmentType = action.environment.type, + ) + } + } + + private fun handlePrivacyPolicyClick() = + sendEvent(StartRegistrationEvent.NavigateToPrivacyPolicy) + + private fun handleTermsClick() = sendEvent(StartRegistrationEvent.NavigateToTerms) + + private fun handleUnsubscribeMarketingEmailsClick() = + sendEvent(StartRegistrationEvent.NavigateToUnsubscribe) + + private fun handleReceiveMarketingEmailsToggle(action: ReceiveMarketingEmailsToggle) { + mutableStateFlow.update { + it.copy(isReceiveMarketingEmailsToggled = action.newState) + } + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy(dialog = null) + } + } + + private fun handleCloseClick() { + sendEvent(StartRegistrationEvent.NavigateBack) + } + + private fun handleEmailInputChanged(action: EmailInputChange) { + mutableStateFlow.update { + it.copy( + emailInput = action.input, + isContinueButtonEnabled = action.input.isNotBlank(), + ) + } + } + + private fun handleNameInputChanged(action: NameInputChange) { + mutableStateFlow.update { + it.copy( + nameInput = action.input, + isContinueButtonEnabled = state.emailInput.isNotBlank(), + ) + } + } + + private fun handleContinueClick() = when { + state.emailInput.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 = StartRegistrationDialog.Error(dialog)) } + } + + !state.emailInput.isValidEmail() -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ) + mutableStateFlow.update { it.copy(dialog = StartRegistrationDialog.Error(dialog)) } + } + + else -> { + submitSendVerificationEmailRequest() + } + } + + private fun submitSendVerificationEmailRequest() { + mutableStateFlow.update { + it.copy(dialog = StartRegistrationDialog.Loading) + } + viewModelScope.launch { + val result = authRepository.sendVerificationEmail( + email = state.emailInput, + name = state.nameInput, + receiveMarketingEmails = state.isReceiveMarketingEmailsToggled, + ) + sendAction( + ReceiveSendVerificationEmailResult( + sendVerificationEmailResult = result, + ), + ) + } + } + + private fun handleReceiveSendVerificationEmailResult( + result: ReceiveSendVerificationEmailResult, + ) { + when (val sendVerificationEmailResult = result.sendVerificationEmailResult) { + + is SendVerificationEmailResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = sendVerificationEmailResult + .errorMessage + ?.asText() + ?: R.string.generic_error_message.asText(), + ), + ), + ) + } + } + + is SendVerificationEmailResult.Success -> { + environmentRepository.saveCurrentEnvironmentForEmail(state.emailInput) + mutableStateFlow.update { it.copy(dialog = null) } + if (sendVerificationEmailResult.emailVerificationToken == null) { + sendEvent( + StartRegistrationEvent.NavigateToCheckEmail( + email = state.emailInput, + ), + ) + } else { + sendEvent( + StartRegistrationEvent.NavigateToCompleteRegistration( + email = state.emailInput, + verificationToken = sendVerificationEmailResult.emailVerificationToken, + ), + ) + } + } + } + } +} + +/** + * UI state for the start registration screen. + */ +@Parcelize +data class StartRegistrationState( + val emailInput: String, + val nameInput: String, + val isReceiveMarketingEmailsToggled: Boolean, + val isContinueButtonEnabled: Boolean, + val selectedEnvironmentType: Type, + val dialog: StartRegistrationDialog?, +) : Parcelable + +/** + * Models dialogs that can be displayed on the start registration screen. + */ +sealed class StartRegistrationDialog : Parcelable { + /** + * Loading dialog. + */ + @Parcelize + data object Loading : StartRegistrationDialog() + + /** + * General error dialog with an OK button. + */ + @Parcelize + data class Error(val state: BasicDialogState.Shown) : StartRegistrationDialog() +} + +/** + * Models events for the start registration screen. + */ +sealed class StartRegistrationEvent { + + /** + * Navigate back to previous screen. + */ + data object NavigateBack : StartRegistrationEvent() + + /** + * Placeholder event for showing a toast. Can be removed once there are real events. + */ + data class ShowToast(val text: String) : StartRegistrationEvent() + + /** + * Navigates to the complete registration screen. + */ + data class NavigateToCompleteRegistration( + val email: String, + val verificationToken: String, + ) : StartRegistrationEvent() + + /** + * Navigates to the check email screen. + */ + data class NavigateToCheckEmail( + val email: String, + ) : StartRegistrationEvent() + + /** + * Navigate to terms and conditions. + */ + data object NavigateToTerms : StartRegistrationEvent() + + /** + * Navigate to privacy policy. + */ + data object NavigateToPrivacyPolicy : StartRegistrationEvent() + + /** + * Navigate to unsubscribe to marketing emails. + */ + data object NavigateToUnsubscribe : StartRegistrationEvent() + + /** + * Navigates to the self-hosted/custom environment screen. + */ + data object NavigateToEnvironment : StartRegistrationEvent() +} + +/** + * Models actions for the start registration screen. + */ +sealed class StartRegistrationAction { + /** + * User clicked continue. + */ + data object ContinueClick : StartRegistrationAction() + + /** + * User clicked close. + */ + data object CloseClick : StartRegistrationAction() + + /** + * Email input changed. + */ + data class EmailInputChange(val input: String) : StartRegistrationAction() + + /** + * Name input changed. + */ + data class NameInputChange(val input: String) : StartRegistrationAction() + + /** + * Indicates that the selection from the region drop down has changed. + */ + data class EnvironmentTypeSelect( + val environmentType: Type, + ) : StartRegistrationAction() + + /** + * User dismissed the error dialog. + */ + data object ErrorDialogDismiss : StartRegistrationAction() + + /** + * User tapped receive marketing emails toggle. + */ + data class ReceiveMarketingEmailsToggle(val newState: Boolean) : StartRegistrationAction() + + /** + * User tapped privacy policy link. + */ + data object PrivacyPolicyClick : StartRegistrationAction() + + /** + * User tapped terms link. + */ + data object TermsClick : StartRegistrationAction() + + /** + * User tapped the unsubscribe link. + */ + data object UnsubscribeMarketingEmailsClick : StartRegistrationAction() + + /** + * Models actions that the [StartRegistrationViewModel] itself might send. + */ + sealed class Internal : StartRegistrationAction() { + /** + * Indicates a [RegisterResult] has been received. + */ + data class ReceiveSendVerificationEmailResult( + val sendVerificationEmailResult: SendVerificationEmailResult, + ) : Internal() + + /** + * Indicates that there has been a change in [environment]. + */ + data class UpdatedEnvironmentReceive( + val environment: Environment, + ) : Internal() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt index 0d64537f1..d8f98ed14 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt @@ -4,9 +4,14 @@ import android.content.res.Resources import android.os.Parcelable import androidx.annotation.PluralsRes import androidx.annotation.StringRes +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue @@ -117,3 +122,47 @@ fun @receiver:StringRes Int.asText(): Text = ResText(this) * Convert a resource Id to [Text] with format args. */ fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList()) + +/** + * Create an [AnnotatedString] with highlighted parts. + * @param mainString the full string + * @param highlights parts of the mainString that will be highlighted + * @param tag the tag that will be used for the annotation + */ +@Composable +fun createAnnotatedString( + mainString: String, + highlights: List, + tag: String, +): AnnotatedString { + return buildAnnotatedString { + append(mainString) + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + ), + start = 0, + end = mainString.length, + ) + for (highlightString in highlights) { + val startIndexUnsubscribe = mainString.indexOf(highlightString, ignoreCase = true) + val endIndexUnsubscribe = startIndexUnsubscribe + highlightString.length + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ), + start = startIndexUnsubscribe, + end = endIndexUnsubscribe, + ) + addStringAnnotation( + tag = tag, + annotation = highlightString, + start = startIndexUnsubscribe, + end = endIndexUnsubscribe, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt new file mode 100644 index 000000000..104eb91b6 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.ui.platform.components.dropdown + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.util.displayLabel + +/** + * A dropdown selector UI component specific to region url selection. + * + * This composable displays a dropdown menu allowing users to select a region + * from a list of options. When an option is selected, it invokes the provided callback + * and displays the currently selected region on the UI. + * + * @param labelText The text displayed near the selector button. + * @param selectedOption The currently selected environment option. + * @param onOptionSelected A callback that gets invoked when an environment option is selected + * and passes the selected option as an argument. + * @param modifier A [Modifier] for the composable. + * + */ +@Composable +fun EnvironmentSelector( + labelText: String, + selectedOption: Environment.Type, + onOptionSelected: (Environment.Type) -> Unit, + modifier: Modifier = Modifier, +) { + val options = Environment.Type.entries.toTypedArray() + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } + + Box(modifier = modifier) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .clickable( + indication = rememberRipple( + bounded = true, + color = MaterialTheme.colorScheme.primary, + ), + interactionSource = remember { MutableInteractionSource() }, + onClick = { shouldShowDialog = !shouldShowDialog }, + ) + .padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = labelText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 12.dp), + ) + Text( + text = selectedOption.displayLabel(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp), + ) + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_region_select_dropdown), + contentDescription = stringResource(id = R.string.region), + tint = MaterialTheme.colorScheme.primary, + ) + } + + if (shouldShowDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.logging_in_on), + onDismissRequest = { shouldShowDialog = false }, + ) { + options.forEach { + BitwardenSelectionRow( + text = it.displayLabel, + onClick = { + onOptionSelected.invoke(it) + shouldShowDialog = false + }, + isSelected = it == selectedOption, + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0395d94bf..0d8e40cf1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,6 +927,18 @@ Do you want to switch to this account? Self-hosted server URL Passkey operation failed because user could not be verified. User verification + Creating on: + Follow the instructions in the email sent to %1$s to continue creating your account. + By continuing, you agree to the Terms of Service and Privacy Policy + Set password + Unsubscribe + Check your email + Open email app + Go back + Email verified + No email? Go back to edit your email address. + Or log in, you may already have an account. + Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time. Privacy, prioritized Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you. Set up biometric unlock and autofill to log into your accounts without typing a single letter. 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 6d1df86a7..badeb8b8f 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 @@ -49,6 +49,7 @@ class LandingScreenTest : BaseComposeTest() { private var onNavigateToCreateAccountCalled = false private var onNavigateToLoginCalled = false private var onNavigateToEnvironmentCalled = false + private var onNavigateToStartRegistrationCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() { onNavigateToLoginCalled = true }, onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true }, + onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true }, viewModel = viewModel, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt new file mode 100644 index 000000000..038882c82 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt @@ -0,0 +1,188 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.core.net.toUri +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange +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 +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class StartRegistrationScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToCompleteRegistrationCalled = false + private var onNavigateToCheckEmailCalled = false + private var onNavigateToEnvironmentCalled = false + + private val intentManager = mockk(relaxed = true) { + every { startCustomTabsActivity(any()) } just runs + every { startActivity(any()) } just runs + } + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + @Before + fun setup() { + composeTestRule.setContent { + StartRegistrationScreen( + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToCompleteRegistration = { _, _ -> + onNavigateToCompleteRegistrationCalled = true + }, + onNavigateToCheckEmail = { _ -> onNavigateToCheckEmailCalled = true }, + onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true }, + intentManager = intentManager, + viewModel = viewModel, + ) + } + } + + @Test + fun `close click should send CloseClick action`() { + composeTestRule.onNodeWithContentDescription("Close").performClick() + verify { viewModel.trySendAction(CloseClick) } + } + + @Test + fun `NavigateBack event should invoke navigate back lambda`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `onNavigateToCompleteRegistration event should invoke navigate to complete registration`() { + mutableEventFlow.tryEmit( + StartRegistrationEvent.NavigateToCompleteRegistration( + email = "email", + verificationToken = "verificationToken", + ), + ) + assertTrue(onNavigateToCompleteRegistrationCalled) + } + + @Test + fun `NavigateToCheckEmail event should invoke navigate to check email`() { + mutableEventFlow.tryEmit( + StartRegistrationEvent.NavigateToCheckEmail( + email = "email", + ), + ) + assertTrue(onNavigateToCheckEmailCalled) + } + + @Test + fun `NavigateToEnvironment event should invoke navigate to environment`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToEnvironment) + assertTrue(onNavigateToEnvironmentCalled) + } + + @Test + fun `NavigateToPrivacyPolicy event should invoke intent manager`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToPrivacyPolicy) + verify { + intentManager.launchUri("https://bitwarden.com/privacy/".toUri()) + } + } + + @Test + fun `NavigateToTerms event should invoke intent manager`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToTerms) + verify { + intentManager.launchUri("https://bitwarden.com/terms/".toUri()) + } + } + + @Test + fun `NavigateToUnsubscribe event should invoke intent manager`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToUnsubscribe) + verify { + intentManager.launchUri("https://bitwarden.com/email-preferences/".toUri()) + } + } + + @Test + fun `email input change should send EmailInputChange action`() { + composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) } + } + + @Test + fun `name input change should send NameInputChange action`() { + composeTestRule.onNodeWithText("Name").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(StartRegistrationAction.NameInputChange(TEST_INPUT)) } + } + + @Test + fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() { + mutableStateFlow.update { + it.copy( + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), + ), + ) + } + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(StartRegistrationAction.ErrorDialogDismiss) } + } + + @Test + fun `when BasicDialogState is Shown should show dialog`() { + mutableStateFlow.update { + it.copy( + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), + ), + ) + } + composeTestRule.onNode(isDialog()).assertIsDisplayed() + } + + companion object { + private const val TEST_INPUT = "input" + private val DEFAULT_STATE = StartRegistrationState( + emailInput = "", + nameInput = "", + isReceiveMarketingEmailsToggled = false, + isContinueButtonEnabled = false, + selectedEnvironmentType = Environment.Type.US, + dialog = null, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt new file mode 100644 index 000000000..22213a185 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt @@ -0,0 +1,455 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult +import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ContinueClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.PrivacyPolicyClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ReceiveMarketingEmailsToggle +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.UnsubscribeMarketingEmailsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateBack +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToCompleteRegistration +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToEnvironment +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToPrivacyPolicy +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToUnsubscribe +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +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.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("LargeClass") +class StartRegistrationViewModelTest : BaseViewModelTest() { + + /** + * Saved state handle that has valid inputs. Useful for tests that want to test things + * after the user has entered all valid inputs. + */ + private val validInputHandle = SavedStateHandle(mapOf("state" to VALID_INPUT_STATE)) + + private val mockAuthRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + } + + private val fakeEnvironmentRepository = FakeEnvironmentRepository() + + @BeforeEach + fun setUp() { + mockkStatic(::generateUriForCaptcha) + } + + @AfterEach + fun tearDown() { + unmockkStatic(::generateUriForCaptcha) + } + + @Test + fun `initial state should be correct`() { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `initial state should pull from saved state handle when present`() { + val savedState = StartRegistrationState( + emailInput = "", + nameInput = "", + isReceiveMarketingEmailsToggled = false, + isContinueButtonEnabled = false, + selectedEnvironmentType = Environment.Type.US, + dialog = null, + ) + val handle = SavedStateHandle(mapOf("state" to savedState)) + val viewModel = StartRegistrationViewModel( + savedStateHandle = handle, + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + assertEquals(savedState, viewModel.stateFlow.value) + } + + @Test + fun `ContinueClick with blank email should show email required`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + val input = "a" + viewModel.trySendAction(EmailInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + emailInput = input, + isContinueButtonEnabled = true, + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ), + ), + ) + viewModel.trySendAction(ContinueClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `ContinueClick with invalid email should show invalid email`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + val input = " " + viewModel.trySendAction(EmailInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + emailInput = input, + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required + .asText(R.string.email_address.asText()), + ), + ), + ) + viewModel.trySendAction(ContinueClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `ContinueClick with all inputs valid should show and hide loading dialog`() = runTest { + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + sendVerificationEmail( + email = EMAIL, + name = NAME, + receiveMarketingEmails = true, + ) + } returns SendVerificationEmailResult.Success( + emailVerificationToken = "verification_token", + ) + } + val viewModel = StartRegistrationViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + environmentRepository = fakeEnvironmentRepository, + ) + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) + viewModel.trySendAction(ContinueClick) + assertEquals( + VALID_INPUT_STATE.copy(dialog = StartRegistrationDialog.Loading), + stateFlow.awaitItem(), + ) + assertEquals( + NavigateToCompleteRegistration(EMAIL, "verification_token"), + eventFlow.awaitItem(), + ) + // Make sure loading dialog is hidden: + assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) + } + } + + @Test + fun `ContinueClick register returns error should update errorDialogState`() = runTest { + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + sendVerificationEmail( + email = EMAIL, + name = NAME, + receiveMarketingEmails = true, + ) + } returns SendVerificationEmailResult.Error(errorMessage = "mock_error") + } + val viewModel = StartRegistrationViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.stateFlow.test { + assertEquals(VALID_INPUT_STATE, awaitItem()) + viewModel.trySendAction(ContinueClick) + assertEquals( + VALID_INPUT_STATE.copy(dialog = StartRegistrationDialog.Loading), + awaitItem(), + ) + assertEquals( + VALID_INPUT_STATE.copy( + dialog = StartRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = "mock_error".asText(), + ), + ), + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ContinueClick register returns Success with emailVerificationToken should emit NavigateToCompleteRegistration`() = + runTest { + val mockkUri = mockk() + every { + generateUriForCaptcha(captchaId = "mock_captcha_id") + } returns mockkUri + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + sendVerificationEmail( + email = EMAIL, + name = NAME, + receiveMarketingEmails = true, + ) + } returns SendVerificationEmailResult.Success( + emailVerificationToken = "verification_token", + ) + } + val viewModel = StartRegistrationViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(ContinueClick) + assertEquals( + NavigateToCompleteRegistration( + email = EMAIL, + verificationToken = "verification_token", + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ContinueClick register returns Success without emailVerificationToken should emit NavigateToCheckEmail`() = + runTest { + val mockkUri = mockk() + every { + generateUriForCaptcha(captchaId = "mock_captcha_id") + } returns mockkUri + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + sendVerificationEmail( + email = EMAIL, + name = NAME, + receiveMarketingEmails = true, + ) + } returns SendVerificationEmailResult.Success( + emailVerificationToken = null, + ) + } + val viewModel = StartRegistrationViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(ContinueClick) + assertEquals( + StartRegistrationEvent.NavigateToCheckEmail( + email = EMAIL, + ), + awaitItem(), + ) + } + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(CloseClick) + assertEquals(NavigateBack, awaitItem()) + } + } + + @Test + fun `PrivacyPolicyClick should emit NavigatePrivacyPolicy`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(PrivacyPolicyClick) + assertEquals(NavigateToPrivacyPolicy, awaitItem()) + } + } + + @Test + fun `TermsClick should emit NavigateToTerms`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(TermsClick) + assertEquals(NavigateToTerms, awaitItem()) + } + } + + @Test + fun `UnsubscribeMarketingEmailsClick should emit NavigateToUnsubscribe`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction(UnsubscribeMarketingEmailsClick) + assertEquals(NavigateToUnsubscribe, awaitItem()) + } + } + + @Test + fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest { + val inputEnvironmentType = Environment.Type.EU + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.stateFlow.test { + awaitItem() + viewModel.trySendAction( + EnvironmentTypeSelect( + inputEnvironmentType, + ), + ) + assertEquals( + DEFAULT_STATE.copy(selectedEnvironmentType = Environment.Type.EU), + awaitItem(), + ) + } + } + + @Test + fun `EnvironmentTypeSelect should emit NavigateToEnvironment for self-hosted`() = runTest { + val inputEnvironmentType = Environment.Type.SELF_HOSTED + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.eventFlow.test { + viewModel.trySendAction( + EnvironmentTypeSelect( + inputEnvironmentType, + ), + ) + assertEquals( + NavigateToEnvironment, + awaitItem(), + ) + } + } + + @Test + fun `EmailInputChange update email`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.trySendAction(EmailInputChange("input")) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + emailInput = "input", + isContinueButtonEnabled = true, + ), + awaitItem(), + ) + } + } + + @Test + fun `NameInputChange update name`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.trySendAction(NameInputChange("input")) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(nameInput = "input"), awaitItem()) + } + } + + @Test + fun `ReceiveMarketingEmailsToggle update isReceiveMarketingEmailsToggled`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + viewModel.trySendAction(ReceiveMarketingEmailsToggle(false)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(isReceiveMarketingEmailsToggled = false), awaitItem()) + } + } + + companion object { + private const val EMAIL = "test@test.com" + private const val NAME = "name" + private val DEFAULT_STATE = StartRegistrationState( + emailInput = "", + nameInput = "", + isReceiveMarketingEmailsToggled = true, + isContinueButtonEnabled = false, + selectedEnvironmentType = Environment.Type.US, + dialog = null, + ) + private val VALID_INPUT_STATE = StartRegistrationState( + emailInput = EMAIL, + nameInput = NAME, + isReceiveMarketingEmailsToggled = true, + isContinueButtonEnabled = true, + selectedEnvironmentType = Environment.Type.US, + dialog = null, + ) + } +}