[Pm 10616] create account start design (#3751)

This commit is contained in:
Dave Severns 2024-08-19 17:47:45 -04:00 committed by GitHub
parent 55b57a605e
commit 57c2e7ee4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 689 additions and 143 deletions

View file

@ -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<LandingState, LandingEvent, LandingAction>(
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() {

View file

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

View file

@ -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.
*/

View file

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

View file

@ -0,0 +1,74 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M9.61,36C9.61,26.06 17.66,18 27.61,18H172.39C182.33,18 190.39,26.06 190.39,36V148.89C190.39,158.83 182.33,166.89 172.39,166.89H27.61C17.66,166.89 9.61,158.83 9.61,148.89V36ZM27.61,22C19.87,22 13.61,28.27 13.61,36V148.89C13.61,156.62 19.87,162.89 27.61,162.89H172.39C180.12,162.89 186.39,156.62 186.39,148.89V36C186.39,28.27 180.12,22 172.39,22H27.61Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,115.51C84.18,115.51 85.07,116.4 85.07,117.51L85.07,132.09C85.07,133.19 84.18,134.09 83.07,134.09C81.97,134.09 81.07,133.19 81.07,132.09L81.07,117.51C81.07,116.4 81.97,115.51 83.07,115.51Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,50.81C84.18,50.81 85.07,51.7 85.07,52.81L85.07,67.39C85.07,68.49 84.18,69.39 83.07,69.39C81.97,69.39 81.07,68.49 81.07,67.39L81.07,52.81C81.07,51.7 81.97,50.81 83.07,50.81Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M66.77,108.75C67.55,109.53 67.55,110.8 66.77,111.58L56.46,121.89C55.68,122.67 54.41,122.67 53.63,121.89C52.85,121.11 52.85,119.84 53.63,119.06L63.94,108.75C64.72,107.97 65.99,107.97 66.77,108.75Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M112.52,63C113.3,63.78 113.3,65.05 112.52,65.83L102.21,76.14C101.43,76.92 100.16,76.92 99.38,76.14C98.6,75.36 98.6,74.09 99.38,73.31L109.69,63C110.47,62.22 111.74,62.22 112.52,63Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M66.77,76.14C65.99,76.92 64.72,76.92 63.94,76.14L53.63,65.83C52.85,65.05 52.85,63.78 53.63,63C54.41,62.22 55.68,62.22 56.46,63L66.77,73.31C67.55,74.09 67.55,75.36 66.77,76.14Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M112.52,121.89C111.74,122.67 110.47,122.67 109.69,121.89L99.38,111.58C98.6,110.8 98.6,109.53 99.38,108.75C100.16,107.97 101.43,107.97 102.21,108.75L112.52,119.06C113.3,119.84 113.3,121.11 112.52,121.89Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M125.04,92.41C125.04,93.51 124.15,94.41 123.04,94.41L108.46,94.41C107.36,94.41 106.46,93.51 106.46,92.41C106.46,91.3 107.36,90.41 108.46,90.41L123.04,90.41C124.15,90.41 125.04,91.3 125.04,92.41Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M59.22,92.41C59.22,93.51 58.32,94.41 57.22,94.41L42.64,94.41C41.53,94.41 40.64,93.51 40.64,92.41C40.64,91.3 41.53,90.41 42.64,90.41L57.22,90.41C58.32,90.41 59.22,91.3 59.22,92.41Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,69.96C70.67,69.96 60.62,80.01 60.62,92.41C60.62,104.81 70.67,114.86 83.07,114.86C95.47,114.86 105.52,104.81 105.52,92.41C105.52,80.01 95.47,69.96 83.07,69.96ZM56.62,92.41C56.62,77.8 68.47,65.96 83.07,65.96C97.68,65.96 109.52,77.8 109.52,92.41C109.52,107.01 97.68,118.86 83.07,118.86C68.47,118.86 56.62,107.01 56.62,92.41Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,76.48C74.28,76.48 67.15,83.61 67.15,92.41C67.15,101.2 74.28,108.33 83.07,108.33C91.87,108.33 99,101.2 99,92.41C99,83.61 91.87,76.48 83.07,76.48ZM65.15,92.41C65.15,82.51 73.17,74.48 83.07,74.48C92.97,74.48 101,82.51 101,92.41C101,102.31 92.97,110.33 83.07,110.33C73.17,110.33 65.15,102.31 65.15,92.41Z"
android:fillColor="#6FD9E2"
android:fillType="evenOdd"/>
<path
android:pathData="M27.27,164.89V171.65C27.27,173.86 29.07,175.65 31.27,175.65H47.52C49.73,175.65 51.52,173.86 51.52,171.65V164.89H55.52V171.65C55.52,176.07 51.94,179.65 47.52,179.65H31.27C26.86,179.65 23.27,176.07 23.27,171.65V164.89H27.27Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M148.47,164.89V171.65C148.47,173.86 150.26,175.65 152.47,175.65H168.72C170.93,175.65 172.72,173.86 172.72,171.65V164.89H176.72V171.65C176.72,176.07 173.14,179.65 168.72,179.65H152.47C148.05,179.65 144.47,176.07 144.47,171.65V164.89H148.47Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M174.72,69.12L177.1,69.12C178.76,69.12 180.1,67.78 180.1,66.12L180.1,45.87C180.1,44.21 178.76,42.87 177.1,42.87L174.72,42.87L174.72,40.87L177.1,40.87C179.86,40.87 182.1,43.11 182.1,45.87L182.1,66.12C182.1,68.88 179.86,71.12 177.1,71.12L174.72,71.12L174.72,69.12Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M174.72,142.02L177.1,142.02C178.76,142.02 180.1,140.68 180.1,139.02L180.1,118.77C180.1,117.11 178.76,115.77 177.1,115.77L174.72,115.77L174.72,113.77L177.1,113.77C179.86,113.77 182.1,116.01 182.1,118.77L182.1,139.02C182.1,141.78 179.86,144.02 177.1,144.02L174.72,144.02L174.72,142.02Z"
android:fillColor="#E2E3E4"
android:fillType="evenOdd"/>
<path
android:pathData="M24.27,41.67C24.27,36.7 28.3,32.67 33.27,32.67H166.72C171.69,32.67 175.72,36.7 175.72,41.67V144.13C175.72,149.1 171.69,153.13 166.72,153.13H33.27C28.3,153.13 24.27,149.1 24.27,144.13V41.67ZM33.27,34.67C29.41,34.67 26.27,37.8 26.27,41.67V144.13C26.27,148 29.41,151.13 33.27,151.13H166.72C170.59,151.13 173.72,148 173.72,144.13V41.67C173.72,37.8 170.59,34.67 166.72,34.67H33.27Z"
android:fillColor="#6FD9E2"
android:fillType="evenOdd"/>
<path
android:pathData="M38.17,42.63C36.52,42.63 35.17,43.97 35.17,45.63V64.36C35.17,64.92 34.73,65.36 34.17,65.36C33.62,65.36 33.17,64.92 33.17,64.36V45.63C33.17,42.87 35.41,40.63 38.17,40.63H123.65C124.2,40.63 124.65,41.08 124.65,41.63C124.65,42.18 124.2,42.63 123.65,42.63H38.17ZM166.76,117.02C167.32,117.02 167.76,117.46 167.76,118.02V140.13C167.76,142.89 165.52,145.13 162.76,145.13H77.05C76.5,145.13 76.05,144.68 76.05,144.13C76.05,143.57 76.5,143.13 77.05,143.13H162.76C164.42,143.13 165.76,141.78 165.76,140.13V118.02C165.76,117.46 166.21,117.02 166.76,117.02Z"
android:fillColor="#6FD9E2"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,74 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M9.61,36C9.61,26.06 17.66,18 27.61,18H172.39C182.33,18 190.39,26.06 190.39,36V148.89C190.39,158.83 182.33,166.89 172.39,166.89H27.61C17.66,166.89 9.61,158.83 9.61,148.89V36ZM27.61,22C19.87,22 13.61,28.27 13.61,36V148.89C13.61,156.62 19.87,162.89 27.61,162.89H172.39C180.12,162.89 186.39,156.62 186.39,148.89V36C186.39,28.27 180.12,22 172.39,22H27.61Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,115.51C84.18,115.51 85.07,116.4 85.07,117.51L85.07,132.09C85.07,133.19 84.18,134.09 83.07,134.09C81.97,134.09 81.07,133.19 81.07,132.09L81.07,117.51C81.07,116.4 81.97,115.51 83.07,115.51Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,50.81C84.18,50.81 85.07,51.7 85.07,52.81L85.07,67.39C85.07,68.49 84.18,69.39 83.07,69.39C81.97,69.39 81.07,68.49 81.07,67.39L81.07,52.81C81.07,51.7 81.97,50.81 83.07,50.81Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M66.77,108.75C67.55,109.53 67.55,110.8 66.77,111.58L56.46,121.89C55.68,122.67 54.41,122.67 53.63,121.89C52.85,121.11 52.85,119.84 53.63,119.06L63.94,108.75C64.72,107.97 65.99,107.97 66.77,108.75Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M112.52,63C113.3,63.78 113.3,65.05 112.52,65.83L102.21,76.14C101.43,76.92 100.16,76.92 99.38,76.14C98.6,75.36 98.6,74.09 99.38,73.31L109.69,63C110.47,62.22 111.74,62.22 112.52,63Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M66.77,76.14C65.99,76.92 64.72,76.92 63.94,76.14L53.63,65.83C52.85,65.05 52.85,63.78 53.63,63C54.41,62.22 55.68,62.22 56.46,63L66.77,73.31C67.55,74.09 67.55,75.36 66.77,76.14Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M112.52,121.89C111.74,122.67 110.47,122.67 109.69,121.89L99.38,111.58C98.6,110.8 98.6,109.53 99.38,108.75C100.16,107.97 101.43,107.97 102.21,108.75L112.52,119.06C113.3,119.84 113.3,121.11 112.52,121.89Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M125.04,92.41C125.04,93.51 124.15,94.41 123.04,94.41L108.46,94.41C107.36,94.41 106.46,93.51 106.46,92.41C106.46,91.3 107.36,90.41 108.46,90.41L123.04,90.41C124.15,90.41 125.04,91.3 125.04,92.41Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M59.22,92.41C59.22,93.51 58.32,94.41 57.22,94.41L42.64,94.41C41.53,94.41 40.64,93.51 40.64,92.41C40.64,91.3 41.53,90.41 42.64,90.41L57.22,90.41C58.32,90.41 59.22,91.3 59.22,92.41Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,69.96C70.67,69.96 60.62,80.01 60.62,92.41C60.62,104.81 70.67,114.86 83.07,114.86C95.47,114.86 105.52,104.81 105.52,92.41C105.52,80.01 95.47,69.96 83.07,69.96ZM56.62,92.41C56.62,77.8 68.47,65.96 83.07,65.96C97.68,65.96 109.52,77.8 109.52,92.41C109.52,107.01 97.68,118.86 83.07,118.86C68.47,118.86 56.62,107.01 56.62,92.41Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M83.07,76.48C74.28,76.48 67.15,83.61 67.15,92.41C67.15,101.2 74.28,108.33 83.07,108.33C91.87,108.33 99,101.2 99,92.41C99,83.61 91.87,76.48 83.07,76.48ZM65.15,92.41C65.15,82.51 73.17,74.48 83.07,74.48C92.97,74.48 101,82.51 101,92.41C101,102.31 92.97,110.33 83.07,110.33C73.17,110.33 65.15,102.31 65.15,92.41Z"
android:fillColor="#10949D"
android:fillType="evenOdd"/>
<path
android:pathData="M27.27,164.89V171.65C27.27,173.86 29.07,175.65 31.27,175.65H47.52C49.73,175.65 51.52,173.86 51.52,171.65V164.89H55.52V171.65C55.52,176.07 51.94,179.65 47.52,179.65H31.27C26.86,179.65 23.27,176.07 23.27,171.65V164.89H27.27Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M148.47,164.89V171.65C148.47,173.86 150.26,175.65 152.47,175.65H168.72C170.93,175.65 172.72,173.86 172.72,171.65V164.89H176.72V171.65C176.72,176.07 173.14,179.65 168.72,179.65H152.47C148.05,179.65 144.47,176.07 144.47,171.65V164.89H148.47Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M174.72,69.12L177.1,69.12C178.76,69.12 180.1,67.78 180.1,66.12L180.1,45.87C180.1,44.21 178.76,42.87 177.1,42.87L174.72,42.87L174.72,40.87L177.1,40.87C179.86,40.87 182.1,43.11 182.1,45.87L182.1,66.12C182.1,68.88 179.86,71.12 177.1,71.12L174.72,71.12L174.72,69.12Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M174.72,142.02L177.1,142.02C178.76,142.02 180.1,140.68 180.1,139.02L180.1,118.77C180.1,117.11 178.76,115.77 177.1,115.77L174.72,115.77L174.72,113.77L177.1,113.77C179.86,113.77 182.1,116.01 182.1,118.77L182.1,139.02C182.1,141.78 179.86,144.02 177.1,144.02L174.72,144.02L174.72,142.02Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M24.27,41.67C24.27,36.7 28.3,32.67 33.27,32.67H166.72C171.69,32.67 175.72,36.7 175.72,41.67V144.13C175.72,149.1 171.69,153.13 166.72,153.13H33.27C28.3,153.13 24.27,149.1 24.27,144.13V41.67ZM33.27,34.67C29.41,34.67 26.27,37.8 26.27,41.67V144.13C26.27,148 29.41,151.13 33.27,151.13H166.72C170.59,151.13 173.72,148 173.72,144.13V41.67C173.72,37.8 170.59,34.67 166.72,34.67H33.27Z"
android:fillColor="#10949D"
android:fillType="evenOdd"/>
<path
android:pathData="M38.17,42.63C36.52,42.63 35.17,43.97 35.17,45.63V64.36C35.17,64.92 34.73,65.36 34.17,65.36C33.62,65.36 33.17,64.92 33.17,64.36V45.63C33.17,42.87 35.41,40.63 38.17,40.63H123.65C124.2,40.63 124.65,41.08 124.65,41.63C124.65,42.18 124.2,42.63 123.65,42.63H38.17ZM166.76,117.02C167.32,117.02 167.76,117.46 167.76,118.02V140.13C167.76,142.89 165.52,145.13 162.76,145.13H77.05C76.5,145.13 76.05,144.68 76.05,144.13C76.05,143.57 76.5,143.13 77.05,143.13H162.76C164.42,143.13 165.76,141.78 165.76,140.13V118.02C165.76,117.46 166.21,117.02 166.76,117.02Z"
android:fillColor="#10949D"
android:fillType="evenOdd"/>
</vector>

View file

@ -975,4 +975,6 @@ Do you want to switch to this account?</string>
<string name="generate_button_label">Generate</string>
<string name="write_this_password_down_and_keep_it_somewhere_safe">Write this password down and keep it somewhere safe.</string>
<string name="learn_about_other_ways_to_prevent_account_lockout">Learn about other ways to prevent account lockout</string>
<string name="help_with_server_geolocations">Help with server geolocations.</string>
<string name="email_address_required">Email address (required)</string>
</resources>

View file

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

View file

@ -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<IntentManager>(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(

View file

@ -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"

View file

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