From 53430cdf8aea4b483dc99f742e9434c20816225d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Mon, 17 Jun 2024 21:25:02 +0100 Subject: [PATCH] [PM-8947] Add marketing toggle and rewrite terms and conditions UI --- .../StartRegistrationScreen.kt | 223 +++++++++++++----- .../StartRegistrationViewModel.kt | 60 +++-- 2 files changed, 204 insertions(+), 79 deletions(-) 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 index c60f48e6be..f540d8826b 100644 --- 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 @@ -6,8 +6,6 @@ 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 @@ -17,14 +15,13 @@ 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.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.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -40,32 +37,33 @@ 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.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.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.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.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.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 @@ -98,6 +96,10 @@ fun StartRegistrationScreen( 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() @@ -152,16 +154,7 @@ fun StartRegistrationScreen( 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 -> @@ -186,7 +179,6 @@ fun StartRegistrationScreen( keyboardType = KeyboardType.Email, ) Spacer(modifier = Modifier.height(2.dp)) - EnvironmentSelector( labelText = stringResource(id = R.string.creating_on), selectedOption = state.selectedEnvironmentType, @@ -198,7 +190,6 @@ fun StartRegistrationScreen( .padding(horizontal = 16.dp) .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(16.dp)) BitwardenTextField( label = stringResource(id = R.string.name), @@ -212,11 +203,29 @@ fun StartRegistrationScreen( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(16.dp)) - TermsAndPrivacySwitch( - isChecked = state.isAcceptPoliciesToggled, - onCheckedChange = remember(viewModel) { - { viewModel.trySendAction(AcceptPoliciesToggle(it)) } + 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) } }, @@ -232,18 +241,131 @@ fun StartRegistrationScreen( @OptIn(ExperimentalLayoutApi::class) @Suppress("LongMethod") @Composable -private fun TermsAndPrivacySwitch( - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, +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 + ), + start = startIndexTerms, + end = endIndexTerms + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize + ), + start = startIndexPrivacy, + end = endIndexPrivacy + ) + + // attach a string annotation that stores a URL to the text "link" + 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 = "AcceptPoliciesToggle" + testTag = "AcceptPoliciesText" + } + .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() + } + } + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@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) + 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 + ), + 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( @@ -262,40 +384,19 @@ private fun TermsAndPrivacySwitch( 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, + ClickableText( + text = annotatedLinkString, + style = MaterialTheme.typography.bodyMedium, + onClick = { + annotatedLinkString + .getStringAnnotations("URL", it, it) + .firstOrNull()?.let { + onUnsubscribeClick() + } + } ) - 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 index 92be3b93fb..174972cd53 100644 --- 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 @@ -10,7 +10,6 @@ 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 @@ -45,7 +44,8 @@ class StartRegistrationViewModel @Inject constructor( ?: StartRegistrationState( emailInput = "", nameInput = "", - isAcceptPoliciesToggled = false, + isReceiveMarketingEmailsToggled = environmentRepository.environment.type == Environment.Type.US, + isContinueButtonEnabled = false, selectedEnvironmentType = environmentRepository.environment.type, dialog = null, ), @@ -85,9 +85,10 @@ class StartRegistrationViewModel @Inject constructor( is NameInputChange -> handleNameInputChanged(action) is CloseClick -> handleCloseClick() is ErrorDialogDismiss -> handleDialogDismiss() - is AcceptPoliciesToggle -> handleAcceptPoliciesToggle(action) + is StartRegistrationAction.ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle(action) is PrivacyPolicyClick -> handlePrivacyPolicyClick() is TermsClick -> handleTermsClick() + is StartRegistrationAction.UnsubscribeMarketingEmailsClick -> handleUnsubscribeMarketingEmailsClick() is StartRegistrationAction.Internal.ReceiveRegisterResult -> { // handleReceiveRegisterAccountResult(action) } @@ -159,9 +160,11 @@ class StartRegistrationViewModel @Inject constructor( private fun handleTermsClick() = sendEvent(StartRegistrationEvent.NavigateToTerms) - private fun handleAcceptPoliciesToggle(action: AcceptPoliciesToggle) { + private fun handleUnsubscribeMarketingEmailsClick() = sendEvent(StartRegistrationEvent.NavigateToUnsubscribe) + + private fun handleReceiveMarketingEmailsToggle(action: StartRegistrationAction.ReceiveMarketingEmailsToggle) { mutableStateFlow.update { - it.copy(isAcceptPoliciesToggled = action.newState) + it.copy(isReceiveMarketingEmailsToggled = action.newState) } } @@ -176,11 +179,21 @@ class StartRegistrationViewModel @Inject constructor( } private fun handleEmailInputChanged(action: EmailInputChange) { - mutableStateFlow.update { it.copy(emailInput = action.input) } + 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) } + mutableStateFlow.update { + it.copy( + nameInput = action.input, + isContinueButtonEnabled = action.input.isNotBlank() && state.emailInput.isNotBlank() + ) + } } private fun handleContinueClick() = when { @@ -210,14 +223,6 @@ class StartRegistrationViewModel @Inject constructor( 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 /* @@ -227,6 +232,14 @@ class StartRegistrationViewModel @Inject constructor( captchaToken = null, ) */ + + viewModelScope.launch { + sendEvent(StartRegistrationEvent.NavigateToCompleteRegistration( + email = state.emailInput, + verificationToken = "", + captchaToken = "" + )) + } } } @@ -261,7 +274,8 @@ class StartRegistrationViewModel @Inject constructor( data class StartRegistrationState( val emailInput: String, val nameInput: String, - val isAcceptPoliciesToggled: Boolean, + val isReceiveMarketingEmailsToggled: Boolean, + val isContinueButtonEnabled: Boolean, val selectedEnvironmentType: Environment.Type, val dialog: StartRegistrationDialog? ) : Parcelable { @@ -324,6 +338,11 @@ sealed class StartRegistrationEvent { */ data object NavigateToPrivacyPolicy : StartRegistrationEvent() + /** + * Navigate to unsubscribe to marketing emails. + */ + data object NavigateToUnsubscribe: StartRegistrationEvent() + /** * Navigates to the self-hosted/custom environment screen. */ @@ -367,9 +386,9 @@ sealed class StartRegistrationAction { data object ErrorDialogDismiss : StartRegistrationAction() /** - * User tapped accept policies toggle. + * User tapped receive marketing emails toggle. */ - data class AcceptPoliciesToggle(val newState: Boolean) : StartRegistrationAction() + data class ReceiveMarketingEmailsToggle(val newState: Boolean) : StartRegistrationAction() /** * User tapped privacy policy link. @@ -381,6 +400,11 @@ sealed class StartRegistrationAction { */ data object TermsClick : StartRegistrationAction() + /** + * User tapped the unsubscribe link. + */ + data object UnsubscribeMarketingEmailsClick : StartRegistrationAction() + /** * Models actions that the [StartRegistrationViewModel] itself might send. */