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.preventaccountlockout.preventAccountLockoutDestination
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
|
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.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.navigateToTwoFactorLogin
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
|
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.welcome.welcomeDestination
|
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(
|
enterpriseSignOnDestination(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToSetPassword = { navController.navigateToSetPassword() },
|
onNavigateToSetPassword = { navController.navigateToSetPassword() },
|
||||||
|
@ -80,6 +92,7 @@ fun NavGraphBuilder.authGraph(
|
||||||
onNavigateToEnvironment = {
|
onNavigateToEnvironment = {
|
||||||
navController.navigateToEnvironment()
|
navController.navigateToEnvironment()
|
||||||
},
|
},
|
||||||
|
onNavigateToStartRegistration = { navController.navigateToStartRegistration() },
|
||||||
)
|
)
|
||||||
welcomeDestination(
|
welcomeDestination(
|
||||||
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
|
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
|
||||||
|
|
|
@ -21,6 +21,7 @@ fun NavGraphBuilder.landingDestination(
|
||||||
onNavigateToCreateAccount: () -> Unit,
|
onNavigateToCreateAccount: () -> Unit,
|
||||||
onNavigateToLogin: (emailAddress: String) -> Unit,
|
onNavigateToLogin: (emailAddress: String) -> Unit,
|
||||||
onNavigateToEnvironment: () -> Unit,
|
onNavigateToEnvironment: () -> Unit,
|
||||||
|
onNavigateToStartRegistration: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composableWithStayTransitions(
|
composableWithStayTransitions(
|
||||||
route = LANDING_ROUTE,
|
route = LANDING_ROUTE,
|
||||||
|
@ -29,6 +30,7 @@ fun NavGraphBuilder.landingDestination(
|
||||||
onNavigateToCreateAccount = onNavigateToCreateAccount,
|
onNavigateToCreateAccount = onNavigateToCreateAccount,
|
||||||
onNavigateToLogin = onNavigateToLogin,
|
onNavigateToLogin = onNavigateToLogin,
|
||||||
onNavigateToEnvironment = onNavigateToEnvironment,
|
onNavigateToEnvironment = onNavigateToEnvironment,
|
||||||
|
onNavigateToStartRegistration = onNavigateToStartRegistration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.landing
|
package com.x8bit.bitwarden.ui.auth.feature.landing
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
@ -34,7 +28,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.testTag
|
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.button.BitwardenTextButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
|
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.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.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.field.BitwardenTextField
|
||||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
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.toggle.BitwardenSwitch
|
||||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||||
import com.x8bit.bitwarden.ui.platform.util.displayLabel
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -75,6 +66,7 @@ fun LandingScreen(
|
||||||
onNavigateToCreateAccount: () -> Unit,
|
onNavigateToCreateAccount: () -> Unit,
|
||||||
onNavigateToLogin: (emailAddress: String) -> Unit,
|
onNavigateToLogin: (emailAddress: String) -> Unit,
|
||||||
onNavigateToEnvironment: () -> Unit,
|
onNavigateToEnvironment: () -> Unit,
|
||||||
|
onNavigateToStartRegistration: () -> Unit,
|
||||||
viewModel: LandingViewModel = hiltViewModel(),
|
viewModel: LandingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
|
@ -86,6 +78,7 @@ fun LandingScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment()
|
LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment()
|
||||||
|
LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,6 +261,7 @@ private fun LandingScreenContent(
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
EnvironmentSelector(
|
EnvironmentSelector(
|
||||||
|
labelText = stringResource(id = R.string.logging_in_on),
|
||||||
selectedOption = state.selectedEnvironmentType,
|
selectedOption = state.selectedEnvironmentType,
|
||||||
onOptionSelected = onEnvironmentTypeSelect,
|
onOptionSelected = onEnvironmentTypeSelect,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
@ -326,82 +320,3 @@ private fun LandingScreenContent(
|
||||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
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() {
|
private fun handleCreateAccountClicked() {
|
||||||
|
// TODO PM-9401: ADD FEATURE FLAG email-verification and navigation to StartRegistration
|
||||||
sendEvent(LandingEvent.NavigateToCreateAccount)
|
sendEvent(LandingEvent.NavigateToCreateAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -291,6 +292,11 @@ sealed class LandingEvent {
|
||||||
*/
|
*/
|
||||||
data object NavigateToCreateAccount : 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.
|
* 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 android.os.Parcelable
|
||||||
import androidx.annotation.PluralsRes
|
import androidx.annotation.PluralsRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.Parcelize
|
||||||
import kotlinx.parcelize.RawValue
|
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.
|
* Convert a resource Id to [Text] with format args.
|
||||||
*/
|
*/
|
||||||
fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList())
|
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="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="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="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="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_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>
|
<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 onNavigateToCreateAccountCalled = false
|
||||||
private var onNavigateToLoginCalled = false
|
private var onNavigateToLoginCalled = false
|
||||||
private var onNavigateToEnvironmentCalled = false
|
private var onNavigateToEnvironmentCalled = false
|
||||||
|
private var onNavigateToStartRegistrationCalled = false
|
||||||
private val mutableEventFlow = bufferedMutableSharedFlow<LandingEvent>()
|
private val mutableEventFlow = bufferedMutableSharedFlow<LandingEvent>()
|
||||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||||
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||||
|
@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() {
|
||||||
onNavigateToLoginCalled = true
|
onNavigateToLoginCalled = true
|
||||||
},
|
},
|
||||||
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
|
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
|
||||||
|
onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
|
||||||
viewModel = viewModel,
|
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