BIT-868 Keep password fields on create account in sync (#125)

This commit is contained in:
Andrew Haisting 2023-10-18 13:29:48 -05:00 committed by Álison Fernandes
parent 9483d58d81
commit 77f693e159
3 changed files with 105 additions and 29 deletions

View file

@ -23,7 +23,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -124,8 +127,11 @@ fun CreateAccountScreen(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
var showPassword by rememberSaveable { mutableStateOf(false) }
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(id = R.string.master_password), label = stringResource(id = R.string.master_password),
showPassword = showPassword,
showPasswordChange = { showPassword = it },
value = state.passwordInput, value = state.passwordInput,
onValueChange = remember(viewModel) { onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) } { viewModel.trySendAction(PasswordInputChange(it)) }
@ -138,6 +144,8 @@ fun CreateAccountScreen(
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password), label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput, value = state.confirmPasswordInput,
showPassword = showPassword,
showPasswordChange = { showPassword = it },
onValueChange = remember(viewModel) { onValueChange = remember(viewModel) {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) } { viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
}, },

View file

@ -20,6 +20,62 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R 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 * Represents a Bitwarden-styled password field that manages the state of a show/hide indicator
* internally. * internally.
@ -40,38 +96,13 @@ fun BitwardenPasswordField(
initialShowPassword: Boolean = false, initialShowPassword: Boolean = false,
) { ) {
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) } var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
OutlinedTextField( BitwardenPasswordField(
modifier = modifier, modifier = modifier,
textStyle = MaterialTheme.typography.bodyLarge, label = label,
label = { Text(text = label) },
value = value, value = value,
showPassword = showPassword,
showPasswordChange = { showPassword = !showPassword },
onValueChange = onValueChange, 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,
)
}
}
},
) )
} }

View file

@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -259,6 +261,41 @@ class CreateAccountScreenTest : BaseComposeTest() {
composeTestRule.onNode(isDialog()).assertIsDisplayed() composeTestRule.onNode(isDialog()).assertIsDisplayed()
} }
@Test
fun `toggling one password field visibility should toggle the other`() {
val viewModel = mockk<CreateAccountViewModel>(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 { companion object {
private const val TEST_INPUT = "input" private const val TEST_INPUT = "input"
private val DEFAULT_STATE = CreateAccountState( private val DEFAULT_STATE = CreateAccountState(