mirror of
https://github.com/bitwarden/android.git
synced 2024-11-22 01:16:02 +03:00
[PM-6702] 4# Start registration screen (#3620)
This commit is contained in:
parent
2bb921b592
commit
eab94dde79
13 changed files with 1691 additions and 89 deletions
|
@ -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() },
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 what’s 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>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue