Add PIN entry dialog and confirmation UI (#625)

This commit is contained in:
Brian Yencho
2024-01-15 16:50:01 -06:00
committed by Álison Fernandes
parent 99d7af4c16
commit 61a162b6de
5 changed files with 544 additions and 22 deletions

View File

@@ -182,17 +182,15 @@ fun AccountSecurityScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenWideSwitch(
label = stringResource(id = R.string.unlock_with_pin),
isChecked = state.isUnlockWithPinEnabled,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(it)) }
UnlockWithPinRow(
isUnlockWithPinEnabled = state.isUnlockWithPinEnabled,
onUnlockWithPinToggleAction = remember(viewModel) {
{ viewModel.trySendAction(it) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.session_timeout),
@@ -289,6 +287,113 @@ fun AccountSecurityScreen(
}
}
@Suppress("LongMethod")
@Composable
private fun UnlockWithPinRow(
isUnlockWithPinEnabled: Boolean,
onUnlockWithPinToggleAction: (AccountSecurityAction.UnlockWithPinToggle) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowPinInputDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowPinConfirmationDialog by rememberSaveable { mutableStateOf(false) }
var pin by rememberSaveable { mutableStateOf("") }
BitwardenWideSwitch(
label = stringResource(id = R.string.unlock_with_pin),
isChecked = isUnlockWithPinEnabled,
onCheckedChange = { isChecked ->
if (isChecked) {
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled,
)
shouldShowPinInputDialog = true
} else {
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
}
},
modifier = modifier,
)
when {
shouldShowPinInputDialog -> {
PinInputDialog(
pin = pin,
onPinChange = { pin = it },
onCancelClick = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
pin = ""
},
onSubmitClick = {
if (pin.isNotEmpty()) {
shouldShowPinInputDialog = false
shouldShowPinConfirmationDialog = true
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled,
)
} else {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
}
},
onDismissRequest = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
pin = ""
},
)
}
shouldShowPinConfirmationDialog -> {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.unlock_with_pin),
message = stringResource(id = R.string.pin_require_master_password_restart),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
onDismissClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
),
)
pin = ""
},
onDismissRequest = {
// Dismissing the dialog is the same as requiring a master password
// confirmation.
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
)
}
}
}
@Suppress("LongMethod")
@Composable
private fun SessionTimeoutRow(

View File

@@ -187,9 +187,19 @@ class AccountSecurityViewModel @Inject constructor(
}
private fun handleUnlockWithPinToggle(action: AccountSecurityAction.UnlockWithPinToggle) {
// TODO BIT-974: Display alert
mutableStateFlow.update { it.copy(isUnlockWithPinEnabled = action.enabled) }
sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()))
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = action.isUnlockWithPinEnabled)
}
// TODO: Complete implementation (BIT-465)
when (action) {
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled -> Unit
AccountSecurityAction.UnlockWithPinToggle.Disabled,
is AccountSecurityAction.UnlockWithPinToggle.Enabled,
-> {
sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()))
}
}
}
}
@@ -357,7 +367,35 @@ sealed class AccountSecurityAction {
/**
* User toggled the unlock with pin switch.
*/
data class UnlockWithPinToggle(
val enabled: Boolean,
) : AccountSecurityAction()
sealed class UnlockWithPinToggle : AccountSecurityAction() {
/**
* Whether or not the action represents PIN unlocking being enabled.
*/
abstract val isUnlockWithPinEnabled: Boolean
/**
* The toggle was disabled.
*/
data object Disabled : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = false
}
/**
* The toggle was enabled but the behavior is pending confirmation.
*/
data object PendingEnabled : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
/**
* The toggle was enabled and the user's [pin] and [shouldRequireMasterPasswordOnRestart]
* preference were confirmed.
*/
data class Enabled(
val pin: String,
val shouldRequireMasterPasswordOnRestart: Boolean,
) : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
}
}

View File

@@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.util.maxDialogHeight
/**
* A dialog for setting a user's PIN.
*
* @param pin The current value of the PIN.
* @param onPinChange A callback for internal changes to the PIN.
* @param onCancelClick A callback for when the "Cancel" button is clicked.
* @param onSubmitClick A callback for when the "Submit" button is clicked.
* @param onDismissRequest A callback for when the dialog is requesting to be dismissed.
*/
@Suppress("LongMethod")
@Composable
fun PinInputDialog(
pin: String,
onPinChange: (String) -> Unit,
onCancelClick: () -> Unit,
onSubmitClick: () -> Unit,
onDismissRequest: () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
) {
val configuration = LocalConfiguration.current
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.requiredHeightIn(
max = configuration.maxDialogHeight,
)
// This background is necessary for the dialog to not be transparent.
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(28.dp),
),
horizontalAlignment = Alignment.End,
) {
Text(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
text = stringResource(id = R.string.enter_pin),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
if (scrollState.canScrollBackward) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(scrollState),
) {
Text(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
text = stringResource(id = R.string.set_pin_description),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
BitwardenTextField(
label = stringResource(id = R.string.pin),
value = pin,
onValueChange = onPinChange,
keyboardType = KeyboardType.Number,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
)
}
if (scrollState.canScrollForward) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(24.dp),
) {
BitwardenTextButton(
label = stringResource(id = R.string.cancel),
onClick = onCancelClick,
)
BitwardenFilledButton(
label = stringResource(id = R.string.submit),
onClick = onSubmitClick,
)
}
}
}
}