diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d1bf963eafa..4a27a2dcfc1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,17 @@
+
+
+
+
+
+
+
+
+
+
+
{
specialCircumstanceManager.specialCircumstance =
@@ -185,6 +188,14 @@ class MainViewModel @Inject constructor(
)
}
+ completeRegistrationData != null -> {
+ specialCircumstanceManager.specialCircumstance =
+ SpecialCircumstance.CompleteRegistration(
+ completeRegistrationData = completeRegistrationData,
+ timestamp = Timestamp.now()
+ )
+ }
+
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt
new file mode 100644
index 00000000000..315267ccf9f
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt
@@ -0,0 +1,19 @@
+package com.x8bit.bitwarden.data.auth.util
+
+import android.content.Intent
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
+
+/**
+ * Checks if the given [Intent] contains data to complete registration.
+ * The [CompleteRegistrationData] will be returned when present.
+ */
+fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
+ val uri = data ?: return null
+ val email = uri?.getQueryParameter("email") ?: return null
+ val verificationToken = uri.getQueryParameter("verificationtoken") ?: return null
+ return CompleteRegistrationData(
+ email = email,
+ verificationToken = verificationToken
+ )
+}
+
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt
new file mode 100644
index 00000000000..f1207f7291a
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt
@@ -0,0 +1,16 @@
+package com.x8bit.bitwarden.data.platform.manager.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Required data to complete ongoing registration process.
+ *
+ * @property email The email of the user creating the account.
+ * @property verificationToken The token required to finish the registration process.
+ */
+@Parcelize
+data class CompleteRegistrationData(
+ val email: String,
+ val verificationToken: String,
+) : Parcelable
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
index a112956b658..709ec46e9f2 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
+import com.google.firebase.Timestamp
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
@@ -48,6 +49,15 @@ sealed class SpecialCircumstance : Parcelable {
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
+ /**
+ * The app was launched via AppLink in order to allow the user complete an ongoing registration.
+ */
+ @Parcelize
+ data class CompleteRegistration(
+ val completeRegistrationData: CompleteRegistrationData,
+ val timestamp: Timestamp
+ ) : SpecialCircumstance()
+
/**
* The app was launched via the credential manager framework in order to allow the user to
* manually save a passkey to their vault.
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
index 0588fcca37e..c8fee015e60 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
@@ -17,6 +17,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
+ is SpecialCircumstance.CompleteRegistration -> null
}
/**
@@ -31,6 +32,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
+ is SpecialCircumstance.CompleteRegistration -> null
}
/**
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt
index 060d550d82a..61f2aa3d5bd 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt
@@ -6,6 +6,10 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
+import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination
+import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
@@ -23,6 +27,8 @@ import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
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
@@ -49,6 +55,28 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
)
+ startRegistrationDestination(
+ onNavigateBack = { navController.popBackStack() },
+ // TODO check necessary parameters
+ onNavigateToCompleteRegistration = { emailAddress, verificationToken, captchaToken ->
+ navController.navigateToCompleteRegistration()
+ },
+ onNavigateToCheckEmail = {emailAddress ->
+ navController.navigateToCheckEmail(emailAddress)
+ },
+ onNavigateToEnvironment = { navController.navigateToEnvironment() }
+ )
+ checkEmailDestination(
+ onNavigateBack = { navController.popBackStack() },
+ onNavigateBackToLanding = {
+ navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
+ })
+ completeRegistrationDestination(
+ onNavigateBack = { navController.popBackStack() },
+ onNavigateToLogin = { emailAddress, captchaToken ->
+ navController.navigateToLogin(emailAddress, captchaToken)
+ },
+ )
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },
@@ -71,6 +99,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
onNavigateToEnvironment = {
navController.navigateToEnvironment()
},
+ onNavigateToStartRegistration = { navController.navigateToStartRegistration()}
)
loginDestination(
onNavigateBack = { navController.popBackStack() },
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt
new file mode 100644
index 00000000000..2a92accf996
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt
@@ -0,0 +1,52 @@
+package com.x8bit.bitwarden.ui.auth.feature.checkemail
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
+import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
+
+private const val EMAIL_ADDRESS: String = "email"
+private const val CHECK_EMAIL_ROUTE: String = "check_email/{$EMAIL_ADDRESS}"
+
+/**
+ * Navigate to the check email screen.
+ */
+fun NavController.navigateToCheckEmail(emailAddress: String, navOptions: NavOptions? = null) {
+ this.navigate("check_email/$emailAddress", navOptions)
+}
+
+/**
+ * Class to retrieve check email arguments from the [SavedStateHandle].
+ */
+@OmitFromCoverage
+data class CheckEmailArgs(
+ val emailAddress: String
+) {
+ constructor(savedStateHandle: SavedStateHandle) : this(
+ emailAddress = checkNotNull(savedStateHandle.get(EMAIL_ADDRESS)),
+ )
+}
+
+/**
+ * Add the check email screen to the nav graph.
+ */
+fun NavGraphBuilder.checkEmailDestination(
+ onNavigateBack: () -> Unit,
+ onNavigateBackToLanding: () -> Unit,
+) {
+ composableWithSlideTransitions(
+ route = CHECK_EMAIL_ROUTE,
+ arguments = listOf(
+ navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
+ )
+ ) {
+ CheckEmailScreen(
+ onNavigateBack = onNavigateBack,
+ onNavigateBackToLanding = onNavigateBackToLanding
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt
new file mode 100644
index 00000000000..7d1f70f9447
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt
@@ -0,0 +1,232 @@
+package com.x8bit.bitwarden.ui.auth.feature.checkemail
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+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.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.graphics.ColorFilter
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+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.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
+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.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
+
+/**
+ * Top level composable for the check email screen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("LongMethod")
+@Composable
+fun CheckEmailScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateBackToLanding: () -> Unit,
+ intentManager: IntentManager = LocalIntentManager.current,
+ viewModel: CheckEmailViewModel = hiltViewModel(),
+) {
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ EventsEffect(viewModel) { event ->
+ when (event) {
+ is CheckEmailEvent.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ is CheckEmailEvent.NavigateToEmailApp -> {
+ val intent = Intent(Intent.ACTION_SENDTO)
+ intent.setData(Uri.parse("mailto:"))
+ intentManager.startActivity(intent)
+ }
+
+ is CheckEmailEvent.NavigateBackToLanding -> {
+ onNavigateBackToLanding.invoke()
+ }
+ }
+ }
+
+ 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(CheckEmailAction.CloseTap) }
+ }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .imePadding()
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+ Image(
+ painter = rememberVectorPainter(id = R.drawable.email_check),
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+ contentDescription = null,
+ contentScale = ContentScale.FillHeight,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .height(112.dp)
+ .fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ text = stringResource(id = R.string.check_your_email),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .wrapContentHeight()
+ .fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ val descriptionAnnotatedString = CreateAnnotatedString(
+ mainText = stringResource(id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account, state.email),
+ highlightText = state.email,
+ highlightSpanStyle = SpanStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ Text(
+ text = descriptionAnnotatedString,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ BitwardenFilledButton(
+ label = stringResource(id = R.string.open_email_app),
+ onClick = remember(viewModel) {
+ { viewModel.trySendAction(CheckEmailAction.OpenEmailTap) }
+ },
+ modifier = Modifier
+ .testTag("OpenEmailApp")
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val goBackAnnotatedString = CreateAnnotatedString(
+ mainText = stringResource(id = R.string.no_email_go_back_to_edit_your_email_address),
+ highlightText = stringResource(id = R.string.go_back)
+ )
+ ClickableText(
+ text = goBackAnnotatedString,
+ onClick = {
+ goBackAnnotatedString
+ .getStringAnnotations("URL", it, it)
+ .firstOrNull()?.let {
+ viewModel.trySendAction(CheckEmailAction.CloseTap)
+ }
+ }
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ val logInAnnotatedString = CreateAnnotatedString(
+ mainText = stringResource(id = R.string.or_log_in_you_may_already_have_an_account),
+ highlightText = stringResource(id = R.string.log_in)
+ )
+ ClickableText(
+ text = logInAnnotatedString,
+ onClick = {
+ logInAnnotatedString
+ .getStringAnnotations("URL", it, it)
+ .firstOrNull()?.let {
+ viewModel.trySendAction(CheckEmailAction.LoginTap)
+ }
+ }
+ )
+ }
+ Spacer(modifier = Modifier.navigationBarsPadding())
+ }
+ }
+}
+
+@Composable
+private fun CreateAnnotatedString(
+ mainText: String,
+ highlightText: String,
+ mainSpanStyle: SpanStyle = SpanStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize
+ ),
+ highlightSpanStyle: SpanStyle = SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+): AnnotatedString {
+ return buildAnnotatedString {
+ val startIndex = mainText.indexOf(highlightText, ignoreCase = true)
+ val endIndex = startIndex + highlightText.length
+ append(mainText)
+ addStyle(
+ style = mainSpanStyle,
+ start = 0,
+ end = mainText.length
+ )
+ addStyle(
+ style = highlightSpanStyle,
+ start = startIndex,
+ end = endIndex
+ )
+ addStringAnnotation(
+ tag = "URL",
+ annotation = highlightText,
+ start = startIndex,
+ end = endIndex
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt
new file mode 100644
index 00000000000..23788b1ace2
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt
@@ -0,0 +1,93 @@
+package com.x8bit.bitwarden.ui.auth.feature.checkemail
+
+import android.os.Parcelable
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
+import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+import javax.inject.Inject
+
+private const val KEY_STATE = "state"
+
+/**
+ * Models logic for the check email screen.
+ */
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class CheckEmailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+) : BaseViewModel(
+ initialState = savedStateHandle[KEY_STATE]
+ ?: CheckEmailState(
+ email = CheckEmailArgs(savedStateHandle).emailAddress
+ ),
+) {
+ init {
+ // As state updates, write to saved state handle:
+ stateFlow
+ .onEach { savedStateHandle[KEY_STATE] = it }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handleAction(action: CheckEmailAction) {
+ when (action) {
+ CheckEmailAction.CloseTap -> sendEvent(CheckEmailEvent.NavigateBack)
+ CheckEmailAction.LoginTap -> sendEvent(CheckEmailEvent.NavigateBackToLanding)
+ CheckEmailAction.OpenEmailTap -> sendEvent(CheckEmailEvent.NavigateToEmailApp)
+ }
+ }
+}
+
+/**
+ * UI state for the check email screen.
+ */
+@Parcelize
+data class CheckEmailState(
+ val email: String
+) : Parcelable {
+
+}
+
+/**
+ * Models events for the check email screen.
+ */
+sealed class CheckEmailEvent {
+
+ /**
+ * Navigate back to previous screen.
+ */
+ data object NavigateBack : CheckEmailEvent()
+
+ /**
+ * Navigate to email app.
+ */
+ data object NavigateToEmailApp : CheckEmailEvent()
+
+ /**
+ * Navigate to email app.
+ */
+ data object NavigateBackToLanding : CheckEmailEvent()
+}
+
+/**
+ * Models actions for the check email screen.
+ */
+sealed class CheckEmailAction {
+ /**
+ * User tapped close.
+ */
+ data object CloseTap : CheckEmailAction()
+
+ /**
+ * User tapped log in.
+ */
+ data object LoginTap : CheckEmailAction()
+
+ /**
+ * User tapped open email.
+ */
+ data object OpenEmailTap : CheckEmailAction()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt
new file mode 100644
index 00000000000..9bda514d0f7
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt
@@ -0,0 +1,34 @@
+package com.x8bit.bitwarden.ui.auth.feature.completeregistration
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
+import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
+
+private const val COMPLETE_REGISTRATION_ROUTE = "complete_registration"
+
+/**
+ * Navigate to the complete registration screen.
+ */
+fun NavController.navigateToCompleteRegistration(navOptions: NavOptions? = null) {
+ this.navigate(COMPLETE_REGISTRATION_ROUTE, navOptions)
+}
+
+/**
+ * Add the complete registration screen to the nav graph.
+ */
+fun NavGraphBuilder.completeRegistrationDestination(
+ onNavigateBack: () -> Unit,
+ onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
+) {
+ composableWithSlideTransitions(
+ route = COMPLETE_REGISTRATION_ROUTE,
+ ) {
+ CompleteRegistrationScreen(
+ onNavigateBack = onNavigateBack,
+ onNavigateToLogin = onNavigateToLogin
+ )
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt
new file mode 100644
index 00000000000..f2639419277
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt
@@ -0,0 +1,241 @@
+package com.x8bit.bitwarden.ui.auth.feature.completeregistration
+
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+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.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.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
+import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
+import com.x8bit.bitwarden.ui.platform.base.util.asText
+import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
+import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
+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.BitwardenTwoButtonDialog
+import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
+import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
+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.composition.LocalIntentManager
+import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
+
+/**
+ * Top level composable for the complete registration screen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("LongMethod")
+@Composable
+fun CompleteRegistrationScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
+ intentManager: IntentManager = LocalIntentManager.current,
+ viewModel: CompleteRegistrationViewModel = hiltViewModel(),
+) {
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ EventsEffect(viewModel) { event ->
+ when (event) {
+ is CompleteRegistrationEvent.NavigateBack -> onNavigateBack.invoke()
+ is CompleteRegistrationEvent.ShowToast -> {
+ Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
+ }
+
+ is CompleteRegistrationEvent.NavigateToCaptcha -> {
+ intentManager.startCustomTabsActivity(uri = event.uri)
+ }
+
+ is CompleteRegistrationEvent.NavigateToLogin -> {
+ onNavigateToLogin(
+ event.email,
+ event.captchaToken,
+ )
+ }
+ }
+ }
+
+ // Show dialog if needed:
+ when (val dialog = state.dialog) {
+ is CompleteRegistrationDialog.Error -> {
+ BitwardenBasicDialog(
+ visibilityState = dialog.state,
+ onDismissRequest = remember(viewModel) {
+ { viewModel.trySendAction(ErrorDialogDismiss) }
+ },
+ )
+ }
+
+ is CompleteRegistrationDialog.HaveIBeenPwned -> {
+ BitwardenTwoButtonDialog(
+ title = dialog.title(),
+ message = dialog.message(),
+ confirmButtonText = stringResource(id = R.string.yes),
+ dismissButtonText = stringResource(id = R.string.no),
+ onConfirmClick = remember(viewModel) {
+ { viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
+ },
+ onDismissClick = remember(viewModel) {
+ { viewModel.trySendAction(ErrorDialogDismiss) }
+ },
+ onDismissRequest = remember(viewModel) {
+ { viewModel.trySendAction(ErrorDialogDismiss) }
+ },
+ )
+ }
+
+ CompleteRegistrationDialog.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.set_password),
+ scrollBehavior = scrollBehavior,
+ navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
+ navigationIconContentDescription = stringResource(id = R.string.close),
+ onNavigationIconClick = remember(viewModel) {
+ { viewModel.trySendAction(CloseClick) }
+ },
+ actions = {
+ BitwardenTextButton(
+ label = stringResource(id = R.string.create_account),
+ onClick = remember(viewModel) {
+ { viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) }
+ },
+ modifier = Modifier.testTag("CreateAccountButton"),
+ )
+ },
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .imePadding()
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(
+ id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
+ state.userEmail
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ var showPassword by rememberSaveable { mutableStateOf(false) }
+ BitwardenPasswordField(
+ label = stringResource(id = R.string.master_password),
+ showPassword = showPassword,
+ showPasswordChange = { showPassword = it },
+ value = state.passwordInput,
+ hint = state.passwordLengthLabel(),
+ onValueChange = remember(viewModel) {
+ { viewModel.trySendAction(PasswordInputChange(it)) }
+ },
+ modifier = Modifier
+ .testTag("MasterPasswordEntry")
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ showPasswordTestTag = "PasswordVisibilityToggle",
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ PasswordStrengthIndicator(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ state = state.passwordStrengthState,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ BitwardenPasswordField(
+ label = stringResource(id = R.string.retype_master_password),
+ value = state.confirmPasswordInput,
+ showPassword = showPassword,
+ showPasswordChange = { showPassword = it },
+ onValueChange = remember(viewModel) {
+ { viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
+ },
+ modifier = Modifier
+ .testTag("ConfirmMasterPasswordEntry")
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ showPasswordTestTag = "ConfirmPasswordVisibilityToggle",
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ BitwardenTextField(
+ label = stringResource(id = R.string.master_password_hint),
+ value = state.passwordHintInput,
+ onValueChange = remember(viewModel) {
+ { viewModel.trySendAction(PasswordHintChange(it)) }
+ },
+ hint = stringResource(id = R.string.master_password_hint_description),
+ modifier = Modifier
+ .testTag("MasterPasswordHintLabel")
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ BitwardenSwitch(
+ label = stringResource(id = R.string.check_known_data_breaches_for_this_password),
+ isChecked = state.isCheckDataBreachesToggled,
+ onCheckedChange = remember(viewModel) {
+ { newState ->
+ viewModel.trySendAction(CheckDataBreachesToggle(newState = newState))
+ }
+ },
+ modifier = Modifier
+ .testTag("CheckExposedMasterPasswordToggle")
+ .padding(horizontal = 16.dp),
+ )
+ Spacer(modifier = Modifier.navigationBarsPadding())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt
new file mode 100644
index 00000000000..6e6c264a4f3
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt
@@ -0,0 +1,524 @@
+package com.x8bit.bitwarden.ui.auth.feature.completeregistration
+
+
+import android.net.Uri
+import android.os.Parcelable
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
+import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
+import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
+import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
+import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
+import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
+import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
+import com.x8bit.bitwarden.ui.platform.base.util.Text
+import com.x8bit.bitwarden.ui.platform.base.util.asText
+import com.x8bit.bitwarden.ui.platform.base.util.concat
+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.Job
+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"
+private const val MIN_PASSWORD_LENGTH = 12
+
+/**
+ * Models logic for the create account screen.
+ */
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class CompleteRegistrationViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val authRepository: AuthRepository
+) : BaseViewModel(
+ initialState = savedStateHandle[KEY_STATE]
+ ?: CompleteRegistrationState(
+ userEmail = "",
+ emailVerificationToken = "",
+ passwordInput = "",
+ confirmPasswordInput = "",
+ passwordHintInput = "",
+ isCheckDataBreachesToggled = true,
+ dialog = null,
+ passwordStrengthState = PasswordStrengthState.NONE,
+ ),
+) {
+
+ /**
+ * Keeps track of async request to get password strength. Should be cancelled
+ * when user input changes.
+ */
+ private var passwordStrengthJob: Job = Job().apply { complete() }
+
+ init {
+ // As state updates, write to saved state handle:
+ stateFlow
+ .onEach { savedStateHandle[KEY_STATE] = it }
+ .launchIn(viewModelScope)
+ authRepository
+ .captchaTokenResultFlow
+ .onEach {
+ sendAction(
+ CompleteRegistrationAction.Internal.ReceiveCaptchaToken(
+ tokenResult = it,
+ ),
+ )
+ }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handleAction(action: CompleteRegistrationAction) {
+ when (action) {
+ is CompleteRegistrationAction.CreateAccountClick -> handleCreateAccountClick()
+ is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
+ is PasswordHintChange -> handlePasswordHintChanged(action)
+ is PasswordInputChange -> handlePasswordInputChanged(action)
+ is CompleteRegistrationAction.CloseClick -> handleCloseClick()
+ is CompleteRegistrationAction.ErrorDialogDismiss -> handleDialogDismiss()
+ is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
+ is CompleteRegistrationAction.Internal.ReceiveRegisterResult -> {
+ handleReceiveRegisterAccountResult(action)
+ }
+
+ is CompleteRegistrationAction.Internal.ReceiveCaptchaToken -> {
+ handleReceiveCaptchaToken(action)
+ }
+
+ ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
+ is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
+ }
+ }
+
+ private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
+ when (val result = action.result) {
+ is PasswordStrengthResult.Success -> {
+ val updatedState = when (result.passwordStrength) {
+ PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
+ PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
+ PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
+ PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
+ PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
+ }
+ mutableStateFlow.update { oldState ->
+ oldState.copy(
+ passwordStrengthState = updatedState,
+ )
+ }
+ }
+
+ PasswordStrengthResult.Error -> {
+ // Leave UI the same
+ }
+ }
+ }
+
+ private fun handleReceiveCaptchaToken(
+ action: CompleteRegistrationAction.Internal.ReceiveCaptchaToken,
+ ) {
+ when (val result = action.tokenResult) {
+ is CaptchaCallbackTokenResult.MissingToken -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = CompleteRegistrationDialog.Error(
+ BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.captcha_failed.asText(),
+ ),
+ ),
+ )
+ }
+ }
+
+ is CaptchaCallbackTokenResult.Success -> {
+ submitRegisterAccountRequest(
+ shouldCheckForDataBreaches = false,
+ shouldIgnorePasswordStrength = true,
+ captchaToken = result.token,
+ )
+ }
+ }
+ }
+
+ @Suppress("LongMethod", "MaxLineLength")
+ private fun handleReceiveRegisterAccountResult(
+ action: CompleteRegistrationAction.Internal.ReceiveRegisterResult,
+ ) {
+ when (val registerAccountResult = action.registerResult) {
+ is RegisterResult.CaptchaRequired -> {
+ mutableStateFlow.update { it.copy(dialog = null) }
+ sendEvent(
+ CompleteRegistrationEvent.NavigateToCaptcha(
+ uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
+ ),
+ )
+ }
+
+ is RegisterResult.Error -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = CompleteRegistrationDialog.Error(
+ BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = registerAccountResult.errorMessage?.asText()
+ ?: R.string.generic_error_message.asText(),
+ ),
+ ),
+ )
+ }
+ }
+
+ is RegisterResult.Success -> {
+ mutableStateFlow.update { it.copy(dialog = null) }
+ sendEvent(
+ CompleteRegistrationEvent.NavigateToLogin(
+ email = state.userEmail,
+ captchaToken = registerAccountResult.captchaToken,
+ ),
+ )
+ }
+
+ RegisterResult.DataBreachFound -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = CompleteRegistrationDialog.HaveIBeenPwned(
+ title = R.string.exposed_master_password.asText(),
+ message = R.string.password_found_in_a_data_breach_alert_description.asText(),
+ ),
+ )
+ }
+ }
+
+ RegisterResult.DataBreachAndWeakPassword -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = CompleteRegistrationDialog.HaveIBeenPwned(
+ title = R.string.weak_and_exposed_master_password.asText(),
+ message = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText(),
+ ),
+ )
+ }
+ }
+
+ RegisterResult.WeakPassword -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = CompleteRegistrationDialog.HaveIBeenPwned(
+ title = R.string.weak_master_password.asText(),
+ message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
+ mutableStateFlow.update {
+ it.copy(isCheckDataBreachesToggled = action.newState)
+ }
+ }
+
+ private fun handleDialogDismiss() {
+ mutableStateFlow.update {
+ it.copy(dialog = null)
+ }
+ }
+
+ private fun handleCloseClick() {
+ sendEvent(CompleteRegistrationEvent.NavigateBack)
+ }
+
+ private fun handlePasswordHintChanged(action: PasswordHintChange) {
+ mutableStateFlow.update { it.copy(passwordHintInput = action.input) }
+ }
+
+ private fun handlePasswordInputChanged(action: PasswordInputChange) {
+ // Update input:
+ mutableStateFlow.update { it.copy(passwordInput = action.input) }
+ // Update password strength:
+ passwordStrengthJob.cancel()
+ if (action.input.isEmpty()) {
+ mutableStateFlow.update {
+ it.copy(passwordStrengthState = PasswordStrengthState.NONE)
+ }
+ } else {
+ passwordStrengthJob = viewModelScope.launch {
+ val result = authRepository.getPasswordStrength(
+ email = state.userEmail,
+ password = action.input,
+ )
+ trySendAction(ReceivePasswordStrengthResult(result))
+ }
+ }
+ }
+
+ private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
+ mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) }
+ }
+
+ private fun handleCreateAccountClick() = when {
+ state.userEmail.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 = CompleteRegistrationDialog.Error(dialog)) }
+ }
+
+ !state.userEmail.isValidEmail() -> {
+ val dialog = BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.invalid_email.asText(),
+ )
+ mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
+ }
+
+ state.passwordInput.length < MIN_PASSWORD_LENGTH -> {
+ val dialog = BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH),
+ )
+ mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
+ }
+
+ state.passwordInput != state.confirmPasswordInput -> {
+ val dialog = BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.master_password_confirmation_val_message.asText(),
+ )
+ mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
+ }
+
+ else -> {
+ submitRegisterAccountRequest(
+ shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
+ shouldIgnorePasswordStrength = false,
+ captchaToken = null,
+ )
+ }
+ }
+
+ private fun handleContinueWithBreachedPasswordClick() {
+ submitRegisterAccountRequest(
+ shouldCheckForDataBreaches = false,
+ shouldIgnorePasswordStrength = true,
+ captchaToken = null,
+ )
+ }
+
+ private fun submitRegisterAccountRequest(
+ shouldCheckForDataBreaches: Boolean,
+ shouldIgnorePasswordStrength: Boolean,
+ captchaToken: String?,
+ ) {
+ mutableStateFlow.update {
+ it.copy(dialog = CompleteRegistrationDialog.Loading)
+ }
+ viewModelScope.launch {
+ val result = authRepository.register(
+ shouldCheckDataBreaches = shouldCheckForDataBreaches,
+ isMasterPasswordStrong = shouldIgnorePasswordStrength ||
+ state.isMasterPasswordStrong,
+ email = state.userEmail,
+ masterPassword = state.passwordInput,
+ masterPasswordHint = state.passwordHintInput.ifBlank { null },
+ captchaToken = captchaToken,
+ )
+ sendAction(
+ CompleteRegistrationAction.Internal.ReceiveRegisterResult(
+ registerResult = result,
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * UI state for the complete registration screen.
+ */
+@Parcelize
+data class CompleteRegistrationState(
+ val userEmail: String,
+ val emailVerificationToken: String,
+ val passwordInput: String,
+ val confirmPasswordInput: String,
+ val passwordHintInput: String,
+ val isCheckDataBreachesToggled: Boolean,
+ val dialog: CompleteRegistrationDialog?,
+ val passwordStrengthState: PasswordStrengthState,
+) : Parcelable {
+
+ val passwordLengthLabel: Text
+ // Have to concat a few strings here, resulting string is:
+ // Important: Your master password cannot be recovered if you forget it! 12
+ // characters minimum
+ @Suppress("MaxLineLength")
+ get() = R.string.important.asText()
+ .concat(
+ ": ".asText(),
+ R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum
+ .asText(MIN_PASSWORD_LENGTH),
+ )
+
+ /**
+ * Whether or not the provided master password is considered strong.
+ */
+ val isMasterPasswordStrong: Boolean
+ get() = when (passwordStrengthState) {
+ PasswordStrengthState.NONE,
+ PasswordStrengthState.WEAK_1,
+ PasswordStrengthState.WEAK_2,
+ PasswordStrengthState.WEAK_3,
+ -> false
+
+ PasswordStrengthState.GOOD,
+ PasswordStrengthState.STRONG,
+ -> true
+ }
+}
+
+/**
+ * Models dialogs that can be displayed on the complete registration screen.
+ */
+sealed class CompleteRegistrationDialog : Parcelable {
+ /**
+ * Loading dialog.
+ */
+ @Parcelize
+ data object Loading : CompleteRegistrationDialog()
+
+ /**
+ * Confirm the user wants to continue with potentially breached password.
+ *
+ * @param title The title for the HaveIBeenPwned dialog.
+ * @param message The message for the HaveIBeenPwned dialog.
+ */
+ @Parcelize
+ data class HaveIBeenPwned(
+ val title: Text,
+ val message: Text,
+ ) : CompleteRegistrationDialog()
+
+ /**
+ * General error dialog with an OK button.
+ */
+ @Parcelize
+ data class Error(val state: BasicDialogState.Shown) : CompleteRegistrationDialog()
+}
+
+/**
+ * Models events for the complete registration screen.
+ */
+sealed class CompleteRegistrationEvent {
+
+ /**
+ * Navigate back to previous screen.
+ */
+ data object NavigateBack : CompleteRegistrationEvent()
+
+ /**
+ * Placeholder event for showing a toast. Can be removed once there are real events.
+ */
+ data class ShowToast(val text: String) : CompleteRegistrationEvent()
+
+ /**
+ * Navigates to the captcha verification screen.
+ */
+ data class NavigateToCaptcha(val uri: Uri) : CompleteRegistrationEvent()
+
+ /**
+ * Navigates to the captcha verification screen.
+ */
+ data class NavigateToLogin(
+ val email: String,
+ val captchaToken: String,
+ ) : CompleteRegistrationEvent()
+}
+
+/**
+ * Models actions for the complete registration screen.
+ */
+sealed class CompleteRegistrationAction {
+ /**
+ * User clicked create account.
+ */
+ data object CreateAccountClick : CompleteRegistrationAction()
+
+ /**
+ * User clicked close.
+ */
+ data object CloseClick : CompleteRegistrationAction()
+
+ /**
+ * User clicked "Yes" when being asked if they are sure they want to use a breached password.
+ */
+ data object ContinueWithBreachedPasswordClick : CompleteRegistrationAction()
+
+ /**
+ * Password input changed.
+ */
+ data class PasswordInputChange(val input: String) : CompleteRegistrationAction()
+
+ /**
+ * Confirm password input changed.
+ */
+ data class ConfirmPasswordInputChange(val input: String) : CompleteRegistrationAction()
+
+ /**
+ * Password hint input changed.
+ */
+ data class PasswordHintChange(val input: String) : CompleteRegistrationAction()
+
+ /**
+ * User dismissed the error dialog.
+ */
+ data object ErrorDialogDismiss : CompleteRegistrationAction()
+
+ /**
+ * User tapped check data breaches toggle.
+ */
+ data class CheckDataBreachesToggle(val newState: Boolean) : CompleteRegistrationAction()
+
+ /**
+ * Models actions that the [CompleteRegistrationViewModel] itself might send.
+ */
+ sealed class Internal : CompleteRegistrationAction() {
+ /**
+ * Indicates a captcha callback token has been received.
+ */
+ data class ReceiveCaptchaToken(
+ val tokenResult: CaptchaCallbackTokenResult,
+ ) : Internal()
+
+ /**
+ * Indicates a [RegisterResult] has been received.
+ */
+ data class ReceiveRegisterResult(
+ val registerResult: RegisterResult,
+ ) : Internal()
+
+ /**
+ * Indicates a password strength result has been received.
+ */
+ data class ReceivePasswordStrengthResult(
+ val result: PasswordStrengthResult,
+ ) : Internal()
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt
similarity index 98%
rename from app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt
rename to app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt
index a9ca846d2dc..eb2fc2cef9a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt
@@ -1,4 +1,4 @@
-package com.x8bit.bitwarden.ui.auth.feature.createaccount
+package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt
index 04fc024ee7a..69eeb69443f 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt
@@ -49,6 +49,7 @@ 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.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt
index 1af6680d1a0..5d6e5b4ed94 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt
index 342a3445fb2..91fd89df4bf 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt
@@ -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
)
}
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt
index 62c875f0f4a..9f1adc7eb01 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt
@@ -58,6 +58,7 @@ 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
@@ -75,6 +76,7 @@ fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
+ onNavigateToStartRegistration: () -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -86,6 +88,7 @@ fun LandingScreen(
)
LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment()
+ LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration()
}
}
@@ -268,6 +271,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 +330,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,
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt
index 9f8d49679a5..9d423c748a3 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt
@@ -160,7 +160,11 @@ class LandingViewModel @Inject constructor(
}
private fun handleCreateAccountClicked() {
- sendEvent(LandingEvent.NavigateToCreateAccount)
+ // TODO ADD FEATURE FLAG email-verification
+ if (true)
+ sendEvent(LandingEvent.NavigateToStartRegistration)
+ else
+ sendEvent(LandingEvent.NavigateToCreateAccount)
}
private fun handleDialogDismiss() {
@@ -245,6 +249,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.
*/
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt
new file mode 100644
index 00000000000..17a2230f1dd
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt
@@ -0,0 +1,40 @@
+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,
+ captchaToken: String
+ ) -> Unit,
+ onNavigateToCheckEmail: (email: String) -> Unit,
+ onNavigateToEnvironment: () -> Unit,
+) {
+ composableWithSlideTransitions(
+ route = START_REGISTRATION_ROUTE,
+ ) {
+ StartRegistrationScreen(
+ onNavigateBack = onNavigateBack,
+ onNavigateToCompleteRegistration = onNavigateToCompleteRegistration,
+ onNavigateToCheckEmail = onNavigateToCheckEmail,
+ onNavigateToEnvironment = onNavigateToEnvironment,
+ )
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt
new file mode 100644
index 00000000000..1866b2882d3
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt
@@ -0,0 +1,409 @@
+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.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
+import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
+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.TermsClick
+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.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
+
+/**
+ * Top level composable for the start registration screen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("LongMethod")
+@Composable
+fun StartRegistrationScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToCompleteRegistration: (
+ emailAddress: String,
+ verificationToken: String,
+ captchaToken: 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.NavigateToCaptcha -> {
+ intentManager.startCustomTabsActivity(uri = event.uri)
+ }
+
+ is StartRegistrationEvent.NavigateToCompleteRegistration -> {
+ onNavigateToCompleteRegistration(
+ event.email,
+ event.verificationToken,
+ event.captchaToken,
+ )
+ }
+
+ 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(StartRegistrationAction.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))
+ ReceiveMarketingEmailsSwitch(
+ isChecked = state.isReceiveMarketingEmailsToggled,
+ onCheckedChange = remember(viewModel) {
+ { viewModel.trySendAction(StartRegistrationAction.ReceiveMarketingEmailsToggle(it)) }
+ },
+ onUnsubscribeClick = remember(viewModel) {
+ { viewModel.trySendAction(StartRegistrationAction.UnsubscribeMarketingEmailsClick) }
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ BitwardenFilledButton(
+ label = stringResource(id = R.string.continue_text),
+ onClick = remember(viewModel) {
+ { viewModel.trySendAction(StartRegistrationAction.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 = "URL",
+ annotation = strTerms,
+ start = startIndexTerms,
+ end = endIndexTerms
+ )
+ addStringAnnotation(
+ 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("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,
+) {
+ val annotatedLinkString: AnnotatedString = buildAnnotatedString {
+ val strMarketingEmail = stringResource(id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time)
+ val strUnsubscribe = stringResource(id = R.string.unsubscribe)
+ val startIndexUnsubscribe = strMarketingEmail.indexOf(strUnsubscribe, ignoreCase = true)
+ val endIndexUnsubscribe = startIndexUnsubscribe + strUnsubscribe.length
+ append(strMarketingEmail)
+ addStyle(
+ style = SpanStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize
+ ),
+ start = 0,
+ end = strMarketingEmail.length
+ )
+ addStyle(
+ style = SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize,
+ fontWeight = FontWeight.Bold
+ ),
+ start = startIndexUnsubscribe,
+ end = endIndexUnsubscribe
+ )
+ addStringAnnotation(
+ tag = "URL",
+ annotation = strUnsubscribe,
+ start = startIndexUnsubscribe,
+ end = endIndexUnsubscribe
+ )
+ }
+ 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("URL", it, it)
+ .firstOrNull()?.let {
+ onUnsubscribeClick()
+ }
+ }
+ )
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt
new file mode 100644
index 00000000000..cb8506c87c2
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt
@@ -0,0 +1,445 @@
+package com.x8bit.bitwarden.ui.auth.feature.startregistration
+
+import android.net.Uri
+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.util.CaptchaCallbackTokenResult
+import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
+import com.x8bit.bitwarden.data.platform.repository.model.Environment
+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.PrivacyPolicyClick
+import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
+import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick
+import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss
+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(
+ initialState = savedStateHandle[KEY_STATE]
+ ?: StartRegistrationState(
+ emailInput = "",
+ nameInput = "",
+ isReceiveMarketingEmailsToggled = environmentRepository.environment.type == Environment.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)
+ authRepository
+ .captchaTokenResultFlow
+ .onEach {
+ sendAction(
+ StartRegistrationAction.Internal.ReceiveCaptchaToken(
+ tokenResult = it,
+ ),
+ )
+ }
+ .launchIn(viewModelScope)
+
+ // Listen for changes in environment triggered both by this VM and externally.
+ environmentRepository
+ .environmentStateFlow
+ .onEach { environment ->
+ sendAction(
+ StartRegistrationAction.Internal.UpdatedEnvironmentReceive(environment = environment),
+ )
+ }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handleAction(action: StartRegistrationAction) {
+ when (action) {
+ is StartRegistrationAction.ContinueClick -> handleContinueClick()
+ is EmailInputChange -> handleEmailInputChanged(action)
+ is NameInputChange -> handleNameInputChanged(action)
+ is CloseClick -> handleCloseClick()
+ is ErrorDialogDismiss -> handleDialogDismiss()
+ is StartRegistrationAction.ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle(action)
+ is PrivacyPolicyClick -> handlePrivacyPolicyClick()
+ is TermsClick -> handleTermsClick()
+ is StartRegistrationAction.UnsubscribeMarketingEmailsClick -> handleUnsubscribeMarketingEmailsClick()
+ is StartRegistrationAction.Internal.ReceiveRegisterResult -> {
+ // handleReceiveRegisterAccountResult(action)
+ }
+ is StartRegistrationAction.Internal.ReceiveCaptchaToken -> {
+ handleReceiveCaptchaToken(action)
+ }
+
+ is StartRegistrationAction.EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action)
+ is StartRegistrationAction.Internal.UpdatedEnvironmentReceive -> {
+ handleUpdatedEnvironmentReceive(action)
+ }
+ }
+ }
+
+ private fun handleReceiveCaptchaToken(
+ action: StartRegistrationAction.Internal.ReceiveCaptchaToken,
+ ) {
+ when (val result = action.tokenResult) {
+ is CaptchaCallbackTokenResult.MissingToken -> {
+ mutableStateFlow.update {
+ it.copy(
+ dialog = StartRegistrationDialog.Error(
+ BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.captcha_failed.asText(),
+ ),
+ ),
+ )
+ }
+ }
+
+ is CaptchaCallbackTokenResult.Success -> {
+ submitRegisterAccountRequest(
+ shouldCheckForDataBreaches = false,
+ shouldIgnorePasswordStrength = true,
+ captchaToken = result.token,
+ )
+ }
+ }
+ }
+
+ private fun handleEnvironmentTypeSelect(action: StartRegistrationAction.EnvironmentTypeSelect) {
+ val environment = when (action.environmentType) {
+ Environment.Type.US -> Environment.Us
+ Environment.Type.EU -> Environment.Eu
+ Environment.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: StartRegistrationAction.Internal.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: StartRegistrationAction.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() && state.nameInput.isNotBlank()
+ )
+ }
+ }
+
+ private fun handleNameInputChanged(action: NameInputChange) {
+ mutableStateFlow.update {
+ it.copy(
+ nameInput = action.input,
+ isContinueButtonEnabled = action.input.isNotBlank() && 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)) }
+ }
+
+ state.nameInput.isBlank() -> {
+ val dialog = BasicDialogState.Shown(
+ title = R.string.an_error_has_occurred.asText(),
+ message = R.string.validation_field_required
+ .asText(R.string.name.asText()),
+ )
+ mutableStateFlow.update { it.copy(dialog = StartRegistrationDialog.Error(dialog)) }
+ }
+
+ else -> {
+ // TODO Call to send verification email
+ /*
+ submitRegisterAccountRequest(
+ shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
+ shouldIgnorePasswordStrength = false,
+ captchaToken = null,
+ )
+ */
+
+ viewModelScope.launch {
+ if (environmentRepository.environment.type == Environment.Type.US || environmentRepository.environment.type == Environment.Type.EU)
+ sendEvent(StartRegistrationEvent.NavigateToCheckEmail(
+ email = state.emailInput
+ ))
+ else
+ sendEvent(StartRegistrationEvent.NavigateToCompleteRegistration(
+ email = state.emailInput,
+ verificationToken = "",
+ captchaToken = ""
+ ))
+ }
+ }
+ }
+
+ private fun submitRegisterAccountRequest(
+ shouldCheckForDataBreaches: Boolean,
+ shouldIgnorePasswordStrength: Boolean,
+ captchaToken: String?,
+ ) {
+ mutableStateFlow.update {
+ it.copy(dialog = StartRegistrationDialog.Loading)
+ }
+ viewModelScope.launch {
+ // TODO change to send email service call
+ /*
+ val result = authRepository.register(
+ email = state.emailInput,
+ captchaToken = captchaToken,
+ )
+ sendAction(
+ StartRegistrationAction.Internal.ReceiveRegisterResult(
+ registerResult = result,
+ ),
+ )*/
+ }
+ }
+}
+
+/**
+ * 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: Environment.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 captcha verification screen.
+ */
+ data class NavigateToCaptcha(val uri: Uri) : StartRegistrationEvent()
+
+ /**
+ * Navigates to the complete registration screen.
+ */
+ data class NavigateToCompleteRegistration(
+ val email: String,
+ val verificationToken: String,
+ val captchaToken: String,
+ ) : StartRegistrationEvent()
+
+ /**
+ * Navigates to the complete registration 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 submit.
+ */
+ 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: Environment.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 captcha callback token has been received.
+ */
+ data class ReceiveCaptchaToken(
+ val tokenResult: CaptchaCallbackTokenResult,
+ ) : Internal()
+
+ /**
+ * Indicates a [RegisterResult] has been received.
+ */
+ data class ReceiveRegisterResult(
+ val registerResult: RegisterResult,
+ ) : Internal()
+
+ /**
+ * Indicates that there has been a change in [environment].
+ */
+ data class UpdatedEnvironmentReceive(
+ val environment: Environment,
+ ) : Internal()
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt
new file mode 100644
index 00000000000..30acb5e351a
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dropdown/EnvironmentSelector.kt
@@ -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,
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
index 23d4c11d48c..3f8a2cd25cb 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
@@ -18,6 +18,7 @@ import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
@@ -95,7 +96,8 @@ fun RootNavScreen(
}
val targetRoute = when (state) {
- RootNavState.Auth -> AUTH_GRAPH_ROUTE
+ RootNavState.Auth,
+ is RootNavState.CompleteOngoingRegistration-> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
@@ -192,6 +194,11 @@ fun RootNavScreen(
navOptions = rootNavOptions,
)
}
+
+ is RootNavState.CompleteOngoingRegistration -> {
+ navController.navigateToAuthGraph(rootNavOptions)
+ navController.navigateToCompleteRegistration()
+ }
}
}
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
index 8fccd422090..01e03fb22fc 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
+import com.google.firebase.Timestamp
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
@@ -60,6 +61,7 @@ class RootNavViewModel @Inject constructor(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
val userState = action.userState
+ val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
!userState.activeAccount.isVaultUnlocked &&
@@ -69,12 +71,21 @@ class RootNavViewModel @Inject constructor(
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
+ specialCircumstance is SpecialCircumstance.CompleteRegistration -> {
+ RootNavState.CompleteOngoingRegistration(
+ email = specialCircumstance.completeRegistrationData.email,
+ verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
+ timestamp = specialCircumstance.timestamp
+ )
+ }
+
+
userState == null ||
!userState.activeAccount.isLoggedIn ||
userState.hasPendingAccountAddition -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> {
- when (val specialCircumstance = action.specialCircumstance) {
+ when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
@@ -105,6 +116,13 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.VaultShortcut,
null,
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
+
+ is SpecialCircumstance.CompleteRegistration ->
+ RootNavState.CompleteOngoingRegistration (
+ email = specialCircumstance.completeRegistrationData.email,
+ verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
+ timestamp = specialCircumstance.timestamp
+ )
}
}
@@ -200,6 +218,16 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object VaultUnlockedForNewSend : RootNavState()
+ /**
+ * App should show the screen to complete an ongoing registration process.
+ */
+ @Parcelize
+ data class CompleteOngoingRegistration (
+ val email: String,
+ val verificationToken: String,
+ val timestamp: Timestamp
+ ) : RootNavState()
+
/**
* App should show the auth confirmation screen for an unlocked user.
*/
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt
index a50ef8c3460..d9390c0cdc7 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt
@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
-import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthIndicator
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButton
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt
index 09759f853e2..f881a017ee3 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt
@@ -15,7 +15,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
-import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
diff --git a/app/src/main/res/drawable/email_check.xml b/app/src/main/res/drawable/email_check.xml
new file mode 100644
index 00000000000..99552cc4333
--- /dev/null
+++ b/app/src/main/res/drawable/email_check.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9c4ea58032d..ae75d9232f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -913,4 +913,15 @@ Do you want to switch to this account?
Passkey operation failed because of missing asset links
Passkey operation failed because app not found in asset links
Passkey operation failed because app could not be verified
+ Creating on:
+ Follow the instructions in the email sent to %1$s to continue creating your account.
+ By continuing, you agree to the Terms of Service and Privacy Policy
+ Set password
+ Unsubscribe
+ Check your email
+ Open email app
+ Go back
+ No email? Go back to edit your email address.
+ Or log in, you may already have an account.
+ Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt
index 0c4e57ed504..72bc69be463 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt
index bf09b1ae8c7..0801b442ec7 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt
@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt
index a7816cd7ca4..02f689c3dda 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt
@@ -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()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk(relaxed = true) {
@@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() {
onNavigateToLoginCalled = true
},
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
+ onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
viewModel = viewModel,
)
}
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt
index 585d2e165a9..e421c20ae9c 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt
@@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
-import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt
index b9e1507cd54..0adc9e588c0 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt
@@ -19,7 +19,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
-import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
+import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat