[PM-8947] Add marketing toggle and rewrite terms and conditions UI

This commit is contained in:
André Bispo
2024-06-17 21:25:02 +01:00
parent da27115906
commit 53430cdf8a
2 changed files with 204 additions and 79 deletions

View File

@@ -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,
)
}
}
}
}

View File

@@ -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.
*/