BIT-188 Add switches and links for check password and terms and privacy (#106)

This commit is contained in:
Andrew Haisting
2023-10-12 11:39:09 -05:00
committed by Álison Fernandes
parent bb9c260160
commit 2cda9db9a2
8 changed files with 435 additions and 47 deletions

View File

@@ -2,33 +2,63 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
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.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.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.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
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
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToPrivacyPolicy
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle
/**
* Top level composable for the create account screen.
@@ -37,12 +67,21 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Composable
fun CreateAccountScreen(
onNavigateBack: () -> Unit,
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
EventsEffect(viewModel) { event ->
when (event) {
is NavigateToPrivacyPolicy -> {
intentHandler.launchUri("https://bitwarden.com/privacy/".toUri())
}
is NavigateToTerms -> {
intentHandler.launchUri("https://bitwarden.com/terms/".toUri())
}
is CreateAccountEvent.NavigateBack -> onNavigateBack.invoke()
is CreateAccountEvent.ShowToast -> {
Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
@@ -57,54 +96,183 @@ fun CreateAccountScreen(
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()),
) {
BitwardenTextButtonTopAppBar(
title = stringResource(id = R.string.create_account),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CreateAccountAction.CloseClick) }
{ viewModel.trySendAction(CloseClick) }
},
buttonText = stringResource(id = R.string.submit),
onButtonClick = remember(viewModel) {
{ viewModel.trySendAction(CreateAccountAction.SubmitClick) }
{ viewModel.trySendAction(SubmitClick) }
},
isButtonEnabled = true,
)
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = spacedBy(16.dp),
) {
BitwardenTextField(
label = stringResource(id = R.string.email_address),
value = state.emailInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(EmailInputChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
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
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
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)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.master_password_hint_description),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(horizontal = 32.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
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
TermsAndPrivacySwitch(
isChecked = state.isAcceptPoliciesToggled,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AcceptPoliciesToggle(it)) }
},
onTermsClick = remember(viewModel) {
{ viewModel.trySendAction(TermsClick) }
},
onPrivacyPolicyClick = remember(viewModel) {
{ viewModel.trySendAction(PrivacyPolicyClick) }
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun TermsAndPrivacySwitch(
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
) {
val clickableStyle = clickableSpanStyle()
val termsString = stringResource(id = R.string.terms_of_service)
val privacyString = stringResource(id = R.string.privacy_policy)
val annotatedString = remember {
buildAnnotatedString {
withStyle(style = clickableStyle) {
pushStringAnnotation(tag = termsString, annotation = termsString)
append("$termsString,")
}
append(" ")
withStyle(style = clickableStyle) {
pushStringAnnotation(tag = privacyString, annotation = privacyString)
append(privacyString)
}
}
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.semantics(mergeDescendants = true) {
customActions = listOf(
CustomAccessibilityAction(
label = termsString,
action = {
onTermsClick.invoke()
true
},
),
CustomAccessibilityAction(
label = privacyString,
action = {
onPrivacyPolicyClick.invoke()
true
},
),
)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange.invoke(!isChecked) },
)
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
.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,
)
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
onValueChange = remember {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
ClickableText(
text = annotatedString,
onClick = { offset ->
annotatedString
.getStringAnnotations(offset, offset)
.firstOrNull()
?.let { span ->
when (span.tag) {
termsString -> onTermsClick.invoke()
privacyString -> onPrivacyPolicyClick.invoke()
else -> onCheckedChange.invoke(!isChecked)
}
} ?: onCheckedChange.invoke(!isChecked)
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = remember { { viewModel.trySendAction(PasswordHintChange(it)) } },
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@@ -4,11 +4,15 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
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
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
@@ -25,6 +29,7 @@ private const val MIN_PASSWORD_LENGTH = 12
/**
* Models logic for the create account screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class CreateAccountViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@@ -35,6 +40,8 @@ class CreateAccountViewModel @Inject constructor(
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = false,
errorDialogState = BasicDialogState.Hidden,
),
) {
@@ -55,6 +62,26 @@ class CreateAccountViewModel @Inject constructor(
is PasswordInputChange -> handlePasswordInputChanged(action)
is CreateAccountAction.CloseClick -> handleCloseClick()
is CreateAccountAction.ErrorDialogDismiss -> handleDialogDismiss()
is AcceptPoliciesToggle -> handleAcceptPoliciesToggle(action)
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
is TermsClick -> handleTermsClick()
}
}
private fun handlePrivacyPolicyClick() = sendEvent(CreateAccountEvent.NavigateToPrivacyPolicy)
private fun handleTermsClick() = sendEvent(CreateAccountEvent.NavigateToTerms)
private fun handleAcceptPoliciesToggle(action: AcceptPoliciesToggle) {
mutableStateFlow.update {
it.copy(isAcceptPoliciesToggled = action.newState)
}
}
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState)
}
}
@@ -108,6 +135,8 @@ data class CreateAccountState(
val passwordInput: String,
val confirmPasswordInput: String,
val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean,
val errorDialogState: BasicDialogState,
) : Parcelable
@@ -125,6 +154,16 @@ sealed class CreateAccountEvent {
* Placeholder event for showing a toast. Can be removed once there are real events.
*/
data class ShowToast(val text: String) : CreateAccountEvent()
/**
* Navigate to terms and conditions.
*/
data object NavigateToTerms : CreateAccountEvent()
/**
* Navigate to privacy policy.
*/
data object NavigateToPrivacyPolicy : CreateAccountEvent()
}
/**
@@ -165,4 +204,24 @@ sealed class CreateAccountAction {
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : CreateAccountAction()
/**
* User tapped check data breaches toggle.
*/
data class CheckDataBreachesToggle(val newState: Boolean) : CreateAccountAction()
/**
* User tapped accept policies toggle.
*/
data class AcceptPoliciesToggle(val newState: Boolean) : CreateAccountAction()
/**
* User tapped privacy policy link.
*/
data object PrivacyPolicyClick : CreateAccountAction()
/**
* User tapped terms link.
*/
data object TermsClick : CreateAccountAction()
}

View File

@@ -26,4 +26,11 @@ class IntentHandler(private val context: Context) {
.build()
.launchUrl(context, uri)
}
/**
* Start an activity to view the given [uri] in an external browser.
*/
fun launchUri(uri: Uri) {
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
}

View File

@@ -1,15 +1,18 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
@@ -34,23 +37,29 @@ fun BitwardenSwitch(
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange?.invoke(!isChecked) },
)
.semantics(mergeDescendants = true) { }
.wrapContentHeight(),
.then(modifier),
) {
Switch(
modifier = Modifier
.padding(vertical = 8.dp)
.height(32.dp)
.width(52.dp),
checked = isChecked,
onCheckedChange = onCheckedChange,
onCheckedChange = null,
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp),
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
)
}
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.ui.platform.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.text.SpanStyle
/**
* Defines a span style for clickable span texts. Useful because spans require a
* [SpanStyle] instead of the typical [TextStyle].
*/
@Composable
@ReadOnlyComposable
fun clickableSpanStyle(): SpanStyle = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontStyle = MaterialTheme.typography.bodyMedium.fontStyle,
fontFamily = MaterialTheme.typography.bodyMedium.fontFamily,
)