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 684527b30..d809c1590 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 @@ -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(), ) } } 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 eec864546..3d26cd6cb 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 @@ -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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt index 9c2dcad97..4c68956b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt @@ -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)) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt index f2bf9ffb3..7bdc910cb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt @@ -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), ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/SpanStyles.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/SpanStyles.kt new file mode 100644 index 000000000..553b665fa --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/SpanStyles.kt @@ -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, +) 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 d3feba1fe..7818d6698 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 @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount +import android.content.Intent +import android.net.Uri import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor @@ -8,7 +10,11 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.core.net.toUri +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 @@ -16,6 +22,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Pas import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import io.mockk.every @@ -56,6 +63,40 @@ class CreateAccountScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(CloseClick) } } + @Test + fun `check data breaches click should send CheckDataBreachesToggle action`() { + val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(CheckDataBreachesToggle(true)) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel) + } + composeTestRule + .onNodeWithText("Check known data breaches for this password") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(CheckDataBreachesToggle(true)) } + } + + @Test + fun `accept policies click should send AcceptPoliciesToggle action`() { + val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(AcceptPoliciesToggle(true)) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel) + } + composeTestRule + .onNodeWithText("By activating this switch you agree", substring = true) + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AcceptPoliciesToggle(true)) } + } + @Test fun `NavigateBack event should invoke navigate back lambda`() { var onNavigateBackCalled = false @@ -70,6 +111,52 @@ class CreateAccountScreenTest : BaseComposeTest() { assert(onNavigateBackCalled) } + @Test + fun `NavigateToPrivacyPolicy event should invoke intent handler`() { + val expectedIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://bitwarden.com/privacy/")) + val intentHandler = mockk<IntentHandler>(relaxed = true) { + every { startActivity(expectedIntent) } returns Unit + } + val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns flowOf(CreateAccountEvent.NavigateToPrivacyPolicy) + } + composeTestRule.setContent { + CreateAccountScreen( + onNavigateBack = {}, + viewModel = viewModel, + intentHandler = intentHandler, + ) + } + verify { + intentHandler.launchUri("https://bitwarden.com/privacy/".toUri()) + } + } + + @Test + fun `NavigateToTerms event should invoke intent handler`() { + val expectedIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://bitwarden.com/terms/")) + val intentHandler = mockk<IntentHandler>(relaxed = true) { + every { startActivity(expectedIntent) } returns Unit + } + val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns flowOf(CreateAccountEvent.NavigateToTerms) + } + composeTestRule.setContent { + CreateAccountScreen( + onNavigateBack = {}, + viewModel = viewModel, + intentHandler = intentHandler, + ) + } + verify { + intentHandler.launchUri("https://bitwarden.com/terms/".toUri()) + } + } + @Test fun `email input change should send EmailInputChange action`() { val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { @@ -179,6 +266,8 @@ class CreateAccountScreenTest : BaseComposeTest() { passwordInput = "", confirmPasswordInput = "", passwordHintInput = "", + isCheckDataBreachesToggled = false, + isAcceptPoliciesToggled = false, errorDialogState = BasicDialogState.Hidden, ) } 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 04c1de9e8..0aed9296a 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 @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Con 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.CreateAccountEvent.ShowToast import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState @@ -30,6 +31,8 @@ class CreateAccountViewModelTest : BaseViewModelTest() { passwordInput = "password", confirmPasswordInput = "confirmPassword", passwordHintInput = "hint", + isCheckDataBreachesToggled = false, + isAcceptPoliciesToggled = false, errorDialogState = BasicDialogState.Hidden, ) val handle = SavedStateHandle(mapOf("state" to savedState)) @@ -61,7 +64,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { viewModel.trySendAction(PasswordInputChange("longenoughpassword")) viewModel.eventFlow.test { viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) - assert(awaitItem() is CreateAccountEvent.ShowToast) + assertEquals(ShowToast("TODO: Handle Submit Click"), awaitItem()) } } @@ -70,7 +73,25 @@ class CreateAccountViewModelTest : BaseViewModelTest() { val viewModel = CreateAccountViewModel(SavedStateHandle()) viewModel.eventFlow.test { viewModel.actionChannel.trySend(CloseClick) - assert(awaitItem() is CreateAccountEvent.NavigateBack) + assertEquals(CreateAccountEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `PrivacyPolicyClick should emit NavigatePrivacyPolicy`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(CreateAccountAction.PrivacyPolicyClick) + assertEquals(CreateAccountEvent.NavigateToPrivacyPolicy, awaitItem()) + } + } + + @Test + fun `TermsClick should emit NavigateToTerms`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(CreateAccountAction.TermsClick) + assertEquals(CreateAccountEvent.NavigateToTerms, awaitItem()) } } @@ -110,12 +131,32 @@ class CreateAccountViewModelTest : BaseViewModelTest() { } } + @Test + fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.trySendAction(CreateAccountAction.CheckDataBreachesToggle(true)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem()) + } + } + + @Test + fun `AcceptPoliciesToggle should change isAcceptPoliciesToggled`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.trySendAction(CreateAccountAction.AcceptPoliciesToggle(true)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(isAcceptPoliciesToggled = true), awaitItem()) + } + } + companion object { private val DEFAULT_STATE = CreateAccountState( passwordInput = "", emailInput = "", confirmPasswordInput = "", passwordHintInput = "", + isCheckDataBreachesToggled = false, + isAcceptPoliciesToggled = false, errorDialogState = BasicDialogState.Hidden, ) } 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 b72864b9d..338ea6f15 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 @@ -2,9 +2,6 @@ package com.x8bit.bitwarden.ui.auth.feature.landing import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.filterToOne -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -69,7 +66,7 @@ class LandingScreenTest : BaseComposeTest() { viewModel = viewModel, ) } - composeTestRule.onNodeWithText("Continue").performClick() + composeTestRule.onNodeWithText("Continue").performScrollTo().performClick() verify { viewModel.trySendAction(LandingAction.ContinueButtonClick) } @@ -97,8 +94,6 @@ class LandingScreenTest : BaseComposeTest() { } composeTestRule .onNodeWithText("Remember me") - .onChildren() - .filterToOne(hasClickAction()) .performClick() verify { viewModel.trySendAction(LandingAction.RememberMeToggle(true))