[PM-10685] Support keyboard Done event as CTA Unlock on Pin\Master Password unlock screen (#3691)

This commit is contained in:
A. Bubnov 2024-08-26 17:57:48 +03:00 committed by GitHub
parent 38e693f92c
commit 88b40cfd10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 112 additions and 0 deletions

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@ -31,6 +32,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -210,6 +212,12 @@ fun VaultUnlockScreen(
.padding(horizontal = 16.dp)
.fillMaxWidth(),
autoFocus = state.showKeyboard,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
},
),
)
Spacer(modifier = Modifier.height(24.dp))
Text(

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.emptyInputDialogMessage
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
@ -200,6 +201,18 @@ class VaultUnlockViewModel @Inject constructor(
private fun handleUnlockClick() {
val activeUserId = authRepository.activeUserId ?: return
if (state.input.isEmpty()) {
mutableStateFlow.update {
it.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
it.vaultUnlockType.emptyInputDialogMessage,
),
)
}
return
}
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
viewModelScope.launch {
val vaultUnlockResult = when (state.vaultUnlockType) {

View file

@ -72,3 +72,9 @@ val VaultUnlockType.unlockScreenKeyboardType: KeyboardType
VaultUnlockType.MASTER_PASSWORD -> KeyboardType.Password
VaultUnlockType.PIN -> KeyboardType.Number
}
/**
* The message to show when user try to unlock vault with empty or blank input.
*/
val VaultUnlockType.emptyInputDialogMessage: Text
get() = R.string.validation_field_required.asText(unlockScreenInputLabel)

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.components.field
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -52,6 +53,7 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
* @param keyboardType The type of keyboard the user has access to when inputting values into
* the password field.
* @param imeAction the preferred IME action for the keyboard to have.
* @param keyboardActions the callbacks of keyboard actions.
*/
@Composable
fun BitwardenPasswordField(
@ -68,6 +70,7 @@ fun BitwardenPasswordField(
autoFocus: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Password,
imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
@ -89,6 +92,7 @@ fun BitwardenPasswordField(
keyboardType = keyboardType,
imeAction = imeAction,
),
keyboardActions = keyboardActions,
supportingText = hint?.let {
{
Text(
@ -144,6 +148,7 @@ fun BitwardenPasswordField(
* @param keyboardType The type of keyboard the user has access to when inputting values into
* the password field.
* @param imeAction the preferred IME action for the keyboard to have.
* @param keyboardActions the callbacks of keyboard actions.
*/
@Composable
fun BitwardenPasswordField(
@ -159,6 +164,7 @@ fun BitwardenPasswordField(
autoFocus: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Password,
imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
BitwardenPasswordField(
@ -175,6 +181,7 @@ fun BitwardenPasswordField(
autoFocus = autoFocus,
keyboardType = keyboardType,
imeAction = imeAction,
keyboardActions = keyboardActions,
)
}

View file

@ -1,5 +1,7 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.NativeKeyEvent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsFocused
@ -12,8 +14,10 @@ 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.performKeyPress
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.requestFocus
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
@ -418,6 +422,19 @@ class VaultUnlockScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
}
@Test
fun `keyboard Done event should send UnlockClick action`() {
val keyEvent = KeyEvent(
NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_ENTER),
)
composeTestRule
.onNodeWithText("Master password")
.performScrollTo()
.requestFocus()
.performKeyPress(keyEvent)
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
}
@Test
fun `state with input and without biometrics should request focus on input field`() {
mutableStateFlow.update { it.copy(hideInput = false, isBiometricEnabled = false) }

View file

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenInputLabel
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
@ -461,6 +462,28 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on UnlockClick for empty password should display error dialog`() {
val password = ""
val initialState = DEFAULT_STATE.copy(
input = password,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
)
val viewModel = createViewModel(state = initialState)
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.validation_field_required.asText(
initialState.vaultUnlockType.unlockScreenInputLabel,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on UnlockClick for password unlock should display error dialog on AuthenticationError`() {
val password = "abcd1234"
@ -598,6 +621,28 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on UnlockClick for empty PIN should display error dialog`() {
val password = ""
val initialState = DEFAULT_STATE.copy(
input = password,
vaultUnlockType = VaultUnlockType.PIN,
)
val viewModel = createViewModel(state = initialState)
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.validation_field_required.asText(
initialState.vaultUnlockType.unlockScreenInputLabel,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on UnlockClick for PIN unlock should display error dialog on AuthenticationError`() {
val pin = "1234"

View file

@ -77,4 +77,20 @@ class VaultUnlockTypeExtensionsTest {
)
}
}
@Test
fun `emptyInputDialogMessage should return the correct title for each type`() {
mapOf(
VaultUnlockType.MASTER_PASSWORD to R.string.validation_field_required.asText(
R.string.master_password.asText(),
),
VaultUnlockType.PIN to R.string.validation_field_required.asText(R.string.pin.asText()),
)
.forEach { (type, expected) ->
assertEquals(
expected,
type.emptyInputDialogMessage,
)
}
}
}