[PM-6702] 4# Start registration screen (#3620)

This commit is contained in:
André Bispo 2024-08-15 17:15:45 +01:00 committed by GitHub
parent 2bb921b592
commit eab94dde79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1691 additions and 89 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -927,6 +927,18 @@ Do you want to switch to this account?</string>
<string name="self_hosted_server_url">Self-hosted server URL</string>
<string name="passkey_operation_failed_because_user_could_not_be_verified">Passkey operation failed because user could not be verified.</string>
<string name="user_verification_direction">User verification</string>
<string name="creating_on">Creating on:</string>
<string name="follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account">Follow the instructions in the email sent to %1$s to continue creating your account.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the Terms of Service and Privacy Policy</string>
<string name="set_password">Set password</string>
<string name="unsubscribe">Unsubscribe</string>
<string name="check_your_email">Check your email</string>
<string name="open_email_app">Open email app</string>
<string name="go_back">Go back</string>
<string name="email_verified">Email verified</string>
<string name="no_email_go_back_to_edit_your_email_address">No email? Go back to edit your email address.</string>
<string name="or_log_in_you_may_already_have_an_account">Or log in, you may already have an account.</string>
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.</string>
<string name="privacy_prioritized">Privacy, prioritized</string>
<string name="welcome_message_1">Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect whats important to you.</string>
<string name="welcome_message_2">Set up biometric unlock and autofill to log into your accounts without typing a single letter.</string>

View file

@ -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<LandingEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() {
onNavigateToLoginCalled = true
},
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
viewModel = viewModel,
)
}

View file

@ -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<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<StartRegistrationEvent>()
private val viewModel = mockk<StartRegistrationViewModel>(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,
)
}
}

View file

@ -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<AuthRepository> {
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<AuthRepository> {
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<AuthRepository> {
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<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
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<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
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,
)
}
}