From bc44043bfca7136aef1c400acb0fff529dacf58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 30 May 2024 22:14:43 +0100 Subject: [PATCH] [PM-6701] Add start registration screen and all the navigation logic necessary in landing screen --- .../ui/auth/feature/auth/AuthNavigation.kt | 11 + .../auth/feature/landing/LandingNavigation.kt | 2 + .../ui/auth/feature/landing/LandingScreen.kt | 2 + .../auth/feature/landing/LandingViewModel.kt | 11 +- .../StartRegistrationNavigation.kt | 38 ++ .../StartRegistrationScreen.kt | 301 +++++++++++++ .../StartRegistrationViewModel.kt | 409 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 8 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt 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 060d550d82..8251bf6a38 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 @@ -23,6 +23,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 +51,14 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { ) }, ) + startRegistrationDestination( + onNavigateBack = { navController.popBackStack() }, + // TODO check necessary parameters + onNavigateToCompleteRegistration = { emailAddress, verificationToken, captchaToken -> + navController.popBackStack() + }, + onNavigateToEnvironment = { navController.navigateToEnvironment() } + ) enterpriseSignOnDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToSetPassword = { navController.navigateToSetPassword() }, @@ -71,6 +81,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/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt index 342a3445fb..91fd89df4b 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 f617c56077..9f1adc7eb0 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 @@ -76,6 +76,7 @@ fun LandingScreen( onNavigateToCreateAccount: () -> Unit, onNavigateToLogin: (emailAddress: String) -> Unit, onNavigateToEnvironment: () -> Unit, + onNavigateToStartRegistration: () -> Unit, viewModel: LandingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -87,6 +88,7 @@ fun LandingScreen( ) LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment() + LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration() } } 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 9f8d49679a..9d423c748a 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 0000000000..8eba86281c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationNavigation.kt @@ -0,0 +1,38 @@ +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, + onNavigateToEnvironment: () -> Unit, +) { + composableWithSlideTransitions( + route = START_REGISTRATION_ROUTE, + ) { + StartRegistrationScreen( + onNavigateBack = onNavigateBack, + onNavigateToCompleteRegistration = onNavigateToCompleteRegistration, + 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 0000000000..c60f48e6be --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt @@ -0,0 +1,301 @@ +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.FlowRow +import androidx.compose.foundation.layout.PaddingValues +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.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +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.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.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.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.AcceptPoliciesToggle +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.PrivacyPolicyClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange +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.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.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.text.BitwardenClickableText +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 create account screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun StartRegistrationScreen( + onNavigateBack: () -> Unit, + onNavigateToCompleteRegistration: ( + emailAddress: String, + verificationToken: String, + captchaToken: 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.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, + ) + } + + 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) } + }, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.continue_text), + onClick = remember(viewModel) { + { viewModel.trySendAction(StartRegistrationAction.ContinueClick) } + }, + modifier = Modifier.testTag("ContinueButton"), + ) + }, + ) + }, + ) { 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)) + TermsAndPrivacySwitch( + isChecked = state.isAcceptPoliciesToggled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AcceptPoliciesToggle(it)) } + }, + onTermsClick = remember(viewModel) { + { viewModel.trySendAction(TermsClick) } + }, + onPrivacyPolicyClick = remember(viewModel) { + { viewModel.trySendAction(PrivacyPolicyClick) } + }, + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +private fun TermsAndPrivacySwitch( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + onTermsClick: () -> Unit, + onPrivacyPolicyClick: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .semantics(mergeDescendants = true) { + testTag = "AcceptPoliciesToggle" + 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)) { + Text( + text = stringResource(id = R.string.accept_policies), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + FlowRow( + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .padding(end = 16.dp) + .fillMaxWidth() + .wrapContentHeight(), + ) { + BitwardenClickableText( + label = stringResource(id = R.string.terms_of_service), + onClick = onTermsClick, + style = MaterialTheme.typography.bodyMedium, + innerPadding = PaddingValues(vertical = 4.dp, horizontal = 0.dp), + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = ",", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 4.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + BitwardenClickableText( + label = stringResource(id = R.string.privacy_policy), + onClick = onPrivacyPolicyClick, + style = MaterialTheme.typography.bodyMedium, + innerPadding = PaddingValues(vertical = 4.dp, horizontal = 0.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} 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 0000000000..92be3b93fb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt @@ -0,0 +1,409 @@ +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.AcceptPoliciesToggle +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 create account 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 = "", + isAcceptPoliciesToggled = 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 AcceptPoliciesToggle -> handleAcceptPoliciesToggle(action) + is PrivacyPolicyClick -> handlePrivacyPolicyClick() + is TermsClick -> handleTermsClick() + 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 handleAcceptPoliciesToggle(action: AcceptPoliciesToggle) { + mutableStateFlow.update { + it.copy(isAcceptPoliciesToggled = 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) } + } + + private fun handleNameInputChanged(action: NameInputChange) { + mutableStateFlow.update { it.copy(nameInput = action.input) } + } + + 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)) } + } + + !state.isAcceptPoliciesToggled -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.accept_policies_error.asText(), + ) + mutableStateFlow.update { it.copy(dialog = StartRegistrationDialog.Error(dialog)) } + } + + else -> { + // TODO Call to send verification email + /* + submitRegisterAccountRequest( + shouldCheckForDataBreaches = state.isCheckDataBreachesToggled, + shouldIgnorePasswordStrength = false, + captchaToken = null, + ) + */ + } + } + + 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 create account screen. + */ +@Parcelize +data class StartRegistrationState( + val emailInput: String, + val nameInput: String, + val isAcceptPoliciesToggled: Boolean, + val selectedEnvironmentType: Environment.Type, + val dialog: StartRegistrationDialog? +) : Parcelable { + +} + +/** + * Models dialogs that can be displayed on the create account 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 create account 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() + + /** + * Navigate to terms and conditions. + */ + data object NavigateToTerms : StartRegistrationEvent() + + /** + * Navigate to privacy policy. + */ + data object NavigateToPrivacyPolicy : StartRegistrationEvent() + + /** + * Navigates to the self-hosted/custom environment screen. + */ + data object NavigateToEnvironment : StartRegistrationEvent() +} + +/** + * Models actions for the create account 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 accept policies toggle. + */ + data class AcceptPoliciesToggle(val newState: Boolean) : StartRegistrationAction() + + /** + * User tapped privacy policy link. + */ + data object PrivacyPolicyClick : StartRegistrationAction() + + /** + * User tapped terms link. + */ + data object TermsClick : 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c4ea58032..50efc90639 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,4 +913,5 @@ 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