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 031c157cec..01eebaf273 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 @@ -6,6 +6,8 @@ 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.UserState +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -34,6 +36,7 @@ class LandingViewModel @Inject constructor( private val authRepository: AuthRepository, private val vaultRepository: VaultRepository, private val environmentRepository: EnvironmentRepository, + private val featureFlagManager: FeatureFlagManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] @@ -191,8 +194,13 @@ class LandingViewModel @Inject constructor( } private fun handleCreateAccountClicked() { - // TODO PM-9401: ADD FEATURE FLAG email-verification and navigation to StartRegistration - sendEvent(LandingEvent.NavigateToCreateAccount) + @Suppress("MaxLineLength") + val navigationEvent = if (featureFlagManager.getFeatureFlag(key = FlagKey.EmailVerification)) { + LandingEvent.NavigateToStartRegistration + } else { + LandingEvent.NavigateToCreateAccount + } + sendEvent(navigationEvent) } private fun handleDialogDismiss() { 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 index b094c0ea2f..688197ce75 100644 --- 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 @@ -1,11 +1,11 @@ package com.x8bit.bitwarden.ui.auth.feature.startregistration import android.widget.Toast +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.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 @@ -13,13 +13,17 @@ 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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.TopAppBarDefaults @@ -33,6 +37,8 @@ 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.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.toggleableState @@ -42,27 +48,24 @@ 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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes 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.auth.feature.startregistration.handlers.StartRegistrationHandler +import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.rememberStartRegistrationHandler 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.base.util.standardHorizontalMargin 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 @@ -74,6 +77,7 @@ 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 +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Constant string to be used in string annotation tag field @@ -98,6 +102,7 @@ fun StartRegistrationScreen( viewModel: StartRegistrationViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handler = rememberStartRegistrationHandler(viewModel = viewModel) val context = LocalContext.current EventsEffect(viewModel) { event -> when (event) { @@ -113,6 +118,12 @@ fun StartRegistrationScreen( intentManager.launchUri("https://bitwarden.com/email-preferences/".toUri()) } + is StartRegistrationEvent.NavigateToServerSelectionInfo -> { + intentManager.launchUri( + uri = "https://bitwarden.com/help/server-geographies/".toUri(), + ) + } + is StartRegistrationEvent.NavigateBack -> onNavigateBack.invoke() is StartRegistrationEvent.ShowToast -> { Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show() @@ -164,11 +175,9 @@ fun StartRegistrationScreen( 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) } - }, + navigationIcon = rememberVectorPainter(id = R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = handler.onBackClick, ) }, ) { innerPadding -> @@ -179,102 +188,138 @@ fun StartRegistrationScreen( .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) } - }, + StartRegistrationContent( + emailInput = state.emailInput, + selectedEnvironmentType = state.selectedEnvironmentType, + nameInput = state.nameInput, + isReceiveMarketingEmailsToggled = state.isReceiveMarketingEmailsToggled, + isContinueButtonEnabled = state.isContinueButtonEnabled, + handler = handler, ) Spacer(modifier = Modifier.navigationBarsPadding()) } } } -@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +private fun StartRegistrationContent( + emailInput: String, + selectedEnvironmentType: Environment.Type, + nameInput: String, + isReceiveMarketingEmailsToggled: Boolean, + isContinueButtonEnabled: Boolean, + handler: StartRegistrationHandler, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Spacer(modifier = Modifier.height(16.dp)) + Image( + painter = rememberVectorPainter(id = R.drawable.vault), + contentDescription = null, + modifier = Modifier + .size(132.dp) + .align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(48.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = nameInput, + onValueChange = handler.onNameInputChange, + modifier = Modifier + .testTag("NameEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextField( + label = if (emailInput.isEmpty()) { + stringResource(R.string.email_address_required) + } else { + stringResource( + id = R.string.email_address, + ) + }, + placeholder = stringResource(R.string.email_address_required), + value = emailInput, + onValueChange = handler.onEmailInputChange, + modifier = Modifier + .testTag("EmailAddressEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + keyboardType = KeyboardType.Email, + ) + Spacer(modifier = Modifier.height(2.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + EnvironmentSelector( + labelText = stringResource(id = R.string.creating_on), + selectedOption = selectedEnvironmentType, + onOptionSelected = handler.onEnvironmentTypeSelect, + modifier = Modifier + .testTag("RegionSelectorDropdown"), + ) + IconButton( + onClick = handler.onServerGeologyHelpClick, + // Align with design but keep accessible touch target of IconButton. + modifier = Modifier.offset(y = (-8f).dp, x = 16.dp), + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_tooltip_small), + contentDescription = stringResource(R.string.help_with_server_geolocations), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + + if (selectedEnvironmentType != Environment.Type.SELF_HOSTED) { + ReceiveMarketingEmailsSwitch( + isChecked = isReceiveMarketingEmailsToggled, + onCheckedChange = handler.onReceiveMarketingEmailsToggle, + onUnsubscribeClick = handler.onUnsubscribeMarketingEmailsClick, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(24.dp)) + } + + BitwardenFilledButton( + label = stringResource(id = R.string.continue_text), + onClick = handler.onContinueClick, + isEnabled = isContinueButtonEnabled, + modifier = Modifier + .testTag("ContinueButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyText( + onTermsClick = handler.onTermsClick, + onPrivacyPolicyClick = handler.onPrivacyPolicyClick, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(4.dp)) + } +} + @Suppress("LongMethod") @Composable private fun TermsAndPrivacyText( onTermsClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, + modifier: Modifier = Modifier, ) { + val strTerms = stringResource(id = R.string.terms_of_service) + val strPrivacy = stringResource(id = R.string.privacy_policy) 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) @@ -322,30 +367,47 @@ private fun TermsAndPrivacyText( Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .semantics(mergeDescendants = true) { testTag = "DisclaimerText" + customActions = listOf( + CustomAccessibilityAction( + label = strTerms, + action = { + onTermsClick() + true + }, + ), + CustomAccessibilityAction( + label = strPrivacy, + action = { + onPrivacyPolicyClick() + true + }, + ), + ) } + .padding(horizontal = 16.dp) .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() - } + ClickableText( + text = annotatedLinkString, + style = MaterialTheme.typography.bodyMedium.copy( + textAlign = TextAlign.Center, + ), + onClick = { + annotatedLinkString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { stringAnnotation -> + if (stringAnnotation.item == termsUrl) { + onTermsClick() + } else { + onPrivacyPolicyClick() } - }, - ) - } + } + }, + ) } } @@ -355,27 +417,37 @@ private fun ReceiveMarketingEmailsSwitch( isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, onUnsubscribeClick: () -> Unit, + modifier: Modifier = Modifier, ) { + val unsubscribeString = stringResource(id = R.string.unsubscribe) @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)), + highlights = listOf(unsubscribeString), tag = TAG_URL, ) Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .semantics(mergeDescendants = true) { testTag = "ReceiveMarketingEmailsToggle" toggleableState = ToggleableState(isChecked) + customActions = listOf( + CustomAccessibilityAction( + label = unsubscribeString, + action = { + onUnsubscribeClick() + true + }, + ), + ) } .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(color = MaterialTheme.colorScheme.primary), onClick = { onCheckedChange.invoke(!isChecked) }, ) - .padding(start = 16.dp) .fillMaxWidth(), ) { Switch( @@ -385,18 +457,69 @@ private fun ReceiveMarketingEmailsSwitch( 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() - } - }, - ) - } + Spacer(modifier = Modifier.width(16.dp)) + ClickableText( + text = annotatedLinkString, + style = MaterialTheme.typography.bodyMedium, + onClick = { + annotatedLinkString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { + onUnsubscribeClick() + } + }, + ) + } +} + +@PreviewScreenSizes +@Composable +private fun StartRegistrationContentPreview_filledout() { + BitwardenTheme { + StartRegistrationContent( + emailInput = "e@mail.com", + selectedEnvironmentType = Environment.Type.US, + nameInput = "Test User", + isReceiveMarketingEmailsToggled = true, + isContinueButtonEnabled = true, + handler = StartRegistrationHandler( + onEmailInputChange = {}, + onNameInputChange = {}, + onEnvironmentTypeSelect = {}, + onContinueClick = {}, + onTermsClick = {}, + onPrivacyPolicyClick = {}, + onReceiveMarketingEmailsToggle = {}, + onUnsubscribeMarketingEmailsClick = {}, + onServerGeologyHelpClick = {}, + onBackClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StartRegistrationContentPreview_empty() { + BitwardenTheme { + StartRegistrationContent( + emailInput = "", + selectedEnvironmentType = Environment.Type.US, + nameInput = "", + isReceiveMarketingEmailsToggled = false, + isContinueButtonEnabled = false, + handler = StartRegistrationHandler( + onEmailInputChange = {}, + onNameInputChange = {}, + onEnvironmentTypeSelect = {}, + onContinueClick = {}, + onTermsClick = {}, + onPrivacyPolicyClick = {}, + onReceiveMarketingEmailsToggle = {}, + onUnsubscribeMarketingEmailsClick = {}, + onServerGeologyHelpClick = {}, + onBackClick = {}, + ), + ) } } 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 index 7f891a3fb5..9a25b3e72b 100644 --- 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 @@ -10,7 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResul 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.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.BackClick 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 @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAc 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.ServerGeologyHelpClick 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.platform.base.BaseViewModel @@ -79,7 +80,7 @@ class StartRegistrationViewModel @Inject constructor( is ContinueClick -> handleContinueClick() is EmailInputChange -> handleEmailInputChanged(action) is NameInputChange -> handleNameInputChanged(action) - is CloseClick -> handleCloseClick() + is BackClick -> handleBackClick() is ErrorDialogDismiss -> handleDialogDismiss() is ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle( action, @@ -96,9 +97,15 @@ class StartRegistrationViewModel @Inject constructor( is UpdatedEnvironmentReceive -> { handleUpdatedEnvironmentReceive(action) } + + ServerGeologyHelpClick -> handleServerGeologyHelpClick() } } + private fun handleServerGeologyHelpClick() { + sendEvent(StartRegistrationEvent.NavigateToServerSelectionInfo) + } + private fun handleEnvironmentTypeSelect(action: EnvironmentTypeSelect) { val environment = when (action.environmentType) { Type.US -> Environment.Us @@ -145,7 +152,7 @@ class StartRegistrationViewModel @Inject constructor( } } - private fun handleCloseClick() { + private fun handleBackClick() { sendEvent(StartRegistrationEvent.NavigateBack) } @@ -330,6 +337,11 @@ sealed class StartRegistrationEvent { * Navigates to the self-hosted/custom environment screen. */ data object NavigateToEnvironment : StartRegistrationEvent() + + /** + * Navigates to the server selection info. + */ + data object NavigateToServerSelectionInfo : StartRegistrationEvent() } /** @@ -342,9 +354,9 @@ sealed class StartRegistrationAction { data object ContinueClick : StartRegistrationAction() /** - * User clicked close. + * User clicked back. */ - data object CloseClick : StartRegistrationAction() + data object BackClick : StartRegistrationAction() /** * Email input changed. @@ -388,6 +400,11 @@ sealed class StartRegistrationAction { */ data object UnsubscribeMarketingEmailsClick : StartRegistrationAction() + /** + * User has tapped the tooltip for the server environment + */ + data object ServerGeologyHelpClick : StartRegistrationAction() + /** * Models actions that the [StartRegistrationViewModel] itself might send. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/handlers/StartRegistrationHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/handlers/StartRegistrationHandler.kt new file mode 100644 index 0000000000..9209c6a6c4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/handlers/StartRegistrationHandler.kt @@ -0,0 +1,87 @@ +package com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationViewModel + +/** + * A collection of handler functions for managing actions within the context of the + * [StartRegistrationScreen]. + */ +data class StartRegistrationHandler( + val onEmailInputChange: (String) -> Unit, + val onNameInputChange: (String) -> Unit, + val onEnvironmentTypeSelect: (Environment.Type) -> Unit, + val onContinueClick: () -> Unit, + val onTermsClick: () -> Unit, + val onPrivacyPolicyClick: () -> Unit, + val onReceiveMarketingEmailsToggle: (Boolean) -> Unit, + val onUnsubscribeMarketingEmailsClick: () -> Unit, + val onServerGeologyHelpClick: () -> Unit, + val onBackClick: () -> Unit, +) { + + companion object { + /** + * Creates an instance of [StartRegistrationHandler] by binding actions to the provided + * [StartRegistrationViewModel]. + */ + fun create(viewModel: StartRegistrationViewModel): StartRegistrationHandler { + return StartRegistrationHandler( + onEmailInputChange = { + viewModel.trySendAction( + StartRegistrationAction.EmailInputChange( + it, + ), + ) + }, + onNameInputChange = { + viewModel.trySendAction( + StartRegistrationAction.NameInputChange( + it, + ), + ) + }, + onEnvironmentTypeSelect = { + viewModel.trySendAction( + StartRegistrationAction.EnvironmentTypeSelect( + it, + ), + ) + }, + onContinueClick = { + viewModel.trySendAction(StartRegistrationAction.ContinueClick) + }, + onTermsClick = { viewModel.trySendAction(StartRegistrationAction.TermsClick) }, + onPrivacyPolicyClick = { + viewModel.trySendAction(StartRegistrationAction.PrivacyPolicyClick) + }, + onReceiveMarketingEmailsToggle = { + viewModel.trySendAction( + StartRegistrationAction.ReceiveMarketingEmailsToggle(it), + ) + }, + onUnsubscribeMarketingEmailsClick = { + viewModel.trySendAction( + StartRegistrationAction.UnsubscribeMarketingEmailsClick, + ) + }, + onServerGeologyHelpClick = { + viewModel.trySendAction(StartRegistrationAction.ServerGeologyHelpClick) + }, + onBackClick = { viewModel.trySendAction(StartRegistrationAction.BackClick) }, + ) + } + } +} + +/** + * Convenience function for creating a [StartRegistrationHandler] instance in a [Composable] + * context. + */ +@Composable +fun rememberStartRegistrationHandler(viewModel: StartRegistrationViewModel) = remember(viewModel) { + StartRegistrationHandler.create(viewModel) +} diff --git a/app/src/main/res/drawable-night/vault.xml b/app/src/main/res/drawable-night/vault.xml new file mode 100644 index 0000000000..3369ba7061 --- /dev/null +++ b/app/src/main/res/drawable-night/vault.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/vault.xml b/app/src/main/res/drawable/vault.xml new file mode 100644 index 0000000000..6a7569b744 --- /dev/null +++ b/app/src/main/res/drawable/vault.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 706c8dcc76..cc35b02fca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -975,4 +975,6 @@ Do you want to switch to this account? Generate Write this password down and keep it somewhere safe. Learn about other ways to prevent account lockout + Help with server geolocations. + Email address (required) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index b94812b797..261e6fb63a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -6,6 +6,8 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -35,6 +37,10 @@ class LandingViewModelTest : BaseViewModelTest() { } private val fakeEnvironmentRepository = FakeEnvironmentRepository() + private val featureFlagManager: FeatureFlagManager = mockk(relaxed = true) { + every { getFeatureFlag(FlagKey.EmailVerification) } returns false + } + @Test fun `initial state should be correct when there is no remembered email`() = runTest { val viewModel = createViewModel() @@ -372,6 +378,20 @@ class LandingViewModelTest : BaseViewModelTest() { } } + @Test + fun `When feature is enabled CreateAccountClick should emit NavigateToStartRegistration`() = + runTest { + every { featureFlagManager.getFeatureFlag(FlagKey.EmailVerification) } returns true + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(LandingAction.CreateAccountClick) + assertEquals( + LandingEvent.NavigateToStartRegistration, + awaitItem(), + ) + } + } + @Test fun `DialogDismiss should clear the active dialog`() { val initialState = DEFAULT_STATE.copy( @@ -549,6 +569,7 @@ class LandingViewModelTest : BaseViewModelTest() { }, vaultRepository = vaultRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, savedStateHandle = savedStateHandle, ) 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 index 038882c82a..84453077cf 100644 --- 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.auth.feature.startregistration +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor @@ -8,16 +9,18 @@ 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.performScrollTo 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.BackClick 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 com.x8bit.bitwarden.ui.util.performCustomAccessibilityAction import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -39,6 +42,7 @@ class StartRegistrationScreenTest : BaseComposeTest() { private val intentManager = mockk(relaxed = true) { every { startCustomTabsActivity(any()) } just runs every { startActivity(any()) } just runs + every { launchUri(any()) } just runs } private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -66,9 +70,9 @@ class StartRegistrationScreenTest : BaseComposeTest() { } @Test - fun `close click should send CloseClick action`() { - composeTestRule.onNodeWithContentDescription("Close").performClick() - verify { viewModel.trySendAction(CloseClick) } + fun `close click should send BackClick action`() { + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify { viewModel.trySendAction(BackClick) } } @Test @@ -130,7 +134,7 @@ class StartRegistrationScreenTest : BaseComposeTest() { @Test fun `email input change should send EmailInputChange action`() { - composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT) + composeTestRule.onNodeWithText("Email address (required)").performTextInput(TEST_INPUT) verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) } } @@ -174,6 +178,98 @@ class StartRegistrationScreenTest : BaseComposeTest() { composeTestRule.onNode(isDialog()).assertIsDisplayed() } + @Test + fun `clicking the server tool tip should send ServerGeologyHelpClickAction`() { + composeTestRule + .onNodeWithContentDescription("Help with server geolocations.") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(StartRegistrationAction.ServerGeologyHelpClick) } + } + + @Test + fun `when NavigateToServerSelectionInfo is observed event should invoke intent manager`() { + mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToServerSelectionInfo) + + verify { + intentManager.launchUri( + uri = "https://bitwarden.com/help/server-geographies/".toUri(), + ) + } + } + + @Test + fun `when environment selected in dialog should send EnvironmentTypeSelect action`() { + val selectedEnvironment = Environment.Eu + + // Clicking to open dialog + composeTestRule + .onNodeWithText(Environment.Us.label) + .performScrollTo() + .performClick() + + // Clicking item on dialog + composeTestRule + .onNodeWithText(selectedEnvironment.label) + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + StartRegistrationAction.EnvironmentTypeSelect(selectedEnvironment.type), + ) + } + + // Make sure dialog is hidden: + composeTestRule + .onNode(isDialog()) + .assertDoesNotExist() + } + + @Test + fun `when continue button clicked should send ContinueClick action`() { + mutableStateFlow.update { + it.copy( + isContinueButtonEnabled = true, + ) + } + composeTestRule + .onNodeWithText("Continue") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(StartRegistrationAction.ContinueClick) } + } + + @Test + fun `when unsubscribe custom action invoked should send UnsubscribeMarketingEmailsClick`() { + @Suppress("MaxLineLength") + composeTestRule + .onNodeWithText("Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.") + .performCustomAccessibilityAction("Unsubscribe") + + verify { viewModel.trySendAction(StartRegistrationAction.UnsubscribeMarketingEmailsClick) } + } + + @Test + fun `when terms and conditions custom action invoked should send TermsClick`() { + composeTestRule + .onNodeWithText("By continuing, you agree to the Terms of Service and Privacy Policy") + .performCustomAccessibilityAction("Terms of Service") + + verify { viewModel.trySendAction(StartRegistrationAction.TermsClick) } + } + + @Test + fun `when privacy policy custom action invoked should send TermsClick`() { + composeTestRule + .onNodeWithText("By continuing, you agree to the Terms of Service and Privacy Policy") + .performCustomAccessibilityAction("Privacy Policy") + + verify { viewModel.trySendAction(StartRegistrationAction.PrivacyPolicyClick) } + } + companion object { private const val TEST_INPUT = "input" private val DEFAULT_STATE = StartRegistrationState( 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 index 22213a1851..bf34853f6e 100644 --- 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 @@ -10,7 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResul 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.BackClick 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 @@ -293,14 +293,14 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { } @Test - fun `CloseClick should emit NavigateBack`() = runTest { + fun `BackClick should emit NavigateBack`() = runTest { val viewModel = StartRegistrationViewModel( savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, ) viewModel.eventFlow.test { - viewModel.trySendAction(CloseClick) + viewModel.trySendAction(BackClick) assertEquals(NavigateBack, awaitItem()) } } @@ -432,6 +432,20 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { } } + @Test + fun `ServerGeologyHelpClickAction should emit NavigateToServerSelectionInfo`() = runTest { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + ) + + viewModel.eventFlow.test { + viewModel.trySendAction(StartRegistrationAction.ServerGeologyHelpClick) + assertEquals(StartRegistrationEvent.NavigateToServerSelectionInfo, awaitItem()) + } + } + companion object { private const val EMAIL = "test@test.com" private const val NAME = "name" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index 96420fae02..7ff0e5b41e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.util +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.SemanticsMatcher @@ -19,6 +20,7 @@ import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.printToString import org.junit.jupiter.api.assertThrows /** @@ -143,3 +145,31 @@ fun ComposeContentTestRule.onFirstNodeWithTextAfterScroll( text: String, ): SemanticsNodeInteraction = onAllNodesWithTextAfterScroll(text).onFirst() + +/** + * A helper used to perform a custom accessibility action on a node with a given [label]. + * + * @throws AssertionError if no action with the given [label] is found. + */ +fun SemanticsNodeInteraction.performCustomAccessibilityAction(label: String) { + val tree = printToString() + fetchSemanticsNode() + .let { + val customActions = it.config[SemanticsActions.CustomActions] + customActions + .find { action -> + action.label == label + } + ?.action + ?.invoke() + ?: throw AssertionError( + """ + No action with label $label + + Available actions: $customActions + in + $tree + """.trimMargin(), + ) + } +}