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 d809c1590..63196b6cf 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 @@ -23,7 +23,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -124,8 +127,11 @@ fun CreateAccountScreen( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(16.dp)) + var showPassword by rememberSaveable { mutableStateOf(false) } BitwardenPasswordField( label = stringResource(id = R.string.master_password), + showPassword = showPassword, + showPasswordChange = { showPassword = it }, value = state.passwordInput, onValueChange = remember(viewModel) { { viewModel.trySendAction(PasswordInputChange(it)) } @@ -138,6 +144,8 @@ fun CreateAccountScreen( BitwardenPasswordField( label = stringResource(id = R.string.retype_master_password), value = state.confirmPasswordInput, + showPassword = showPassword, + showPasswordChange = { showPassword = it }, onValueChange = remember(viewModel) { { viewModel.trySendAction(ConfirmPasswordInputChange(it)) } }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt index 21af4b116..0d171f773 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt @@ -20,6 +20,62 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import com.x8bit.bitwarden.R +/** + * Represents a Bitwarden-styled password field that hoists show/hide password state to the caller. + * + * See overloaded [BitwardenPasswordField] for self managed show/hide state. + * + * @param label Label for the text field. + * @param value Current next on the text field. + * @param showPassword Whether or not password should be shown. + * @param showPasswordChange Lambda that is called when user request show/hide be toggled. + * @param onValueChange Callback that is triggered when the password changes. + * @param modifier Modifier for the composable. + */ +@Composable +fun BitwardenPasswordField( + label: String, + value: String, + showPassword: Boolean, + showPasswordChange: (Boolean) -> Unit, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + modifier = modifier, + textStyle = MaterialTheme.typography.bodyLarge, + label = { Text(text = label) }, + value = value, + onValueChange = onValueChange, + visualTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton( + onClick = { showPasswordChange.invoke(!showPassword) }, + ) { + if (showPassword) { + Icon( + painter = painterResource(id = R.drawable.ic_visibility_off), + contentDescription = stringResource(id = R.string.hide), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_visibility), + contentDescription = stringResource(id = R.string.show), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) +} + /** * Represents a Bitwarden-styled password field that manages the state of a show/hide indicator * internally. @@ -40,38 +96,13 @@ fun BitwardenPasswordField( initialShowPassword: Boolean = false, ) { var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) } - OutlinedTextField( + BitwardenPasswordField( modifier = modifier, - textStyle = MaterialTheme.typography.bodyLarge, - label = { Text(text = label) }, + label = label, value = value, + showPassword = showPassword, + showPasswordChange = { showPassword = !showPassword }, onValueChange = onValueChange, - visualTransformation = if (showPassword) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - ) { - if (showPassword) { - Icon( - painter = painterResource(id = R.drawable.ic_visibility_off), - contentDescription = stringResource(id = R.string.hide), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Icon( - painter = painterResource(id = R.drawable.ic_visibility), - contentDescription = stringResource(id = R.string.show), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, ) } 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 7818d6698..70b3cece8 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 @@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount import android.content.Intent import android.net.Uri +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -259,6 +261,41 @@ class CreateAccountScreenTest : BaseComposeTest() { composeTestRule.onNode(isDialog()).assertIsDisplayed() } + @Test + fun `toggling one password field visibility should toggle the other`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + } + composeTestRule.setContent { + CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel) + } + + // should start with 2 Show buttons: + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(2) + .get(0) + .performClick() + + // after clicking there should be no Show buttons: + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(0) + + // and there should be 2 hide buttons now, and we'll click the second one: + composeTestRule + .onAllNodesWithContentDescription("Hide") + .assertCountEquals(2) + .get(1) + .performClick() + + // then there should be two show buttons again + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(2) + } + companion object { private const val TEST_INPUT = "input" private val DEFAULT_STATE = CreateAccountState(