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