Add BitwardenStepper component (#226)

This commit is contained in:
Andrew Haisting 2023-11-08 12:12:17 -06:00 committed by Álison Fernandes
parent 0185456dca
commit bcffbe6fce
8 changed files with 323 additions and 144 deletions

View file

@ -0,0 +1,73 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
/**
* Displays a stepper that allows the user to increment and decrement an int value.
*
* @param label Label for the stepper.
* @param value Value to display. Null will display nothing. Will be clamped to [range] before
* display.
* @param onValueChange callback invoked when the user increments or decrements the count. Note
* that this will not be called if the attempts to move value outside of [range].
* @param modifier Modifier.
* @param range Range of valid values.
* @param isIncrementEnabled whether or not the increment button should be enabled.
* @param isDecrementEnabled whether or not the decrement button should be enabled.
*/
@Composable
fun BitwardenStepper(
label: String,
value: Int?,
onValueChange: (Int) -> Unit,
modifier: Modifier = Modifier,
range: ClosedRange<Int> = 1..Int.MAX_VALUE,
isIncrementEnabled: Boolean = true,
isDecrementEnabled: Boolean = true,
) {
val clampedValue = value?.coerceIn(range)
if (clampedValue != value && clampedValue != null) {
onValueChange(clampedValue)
}
BitwardenReadOnlyTextFieldWithActions(
label = label,
// we use a space instead of empty string to make sure label is shown small and above
// the input
value = clampedValue
?.toString()
?: " ",
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
val decrementedValue = ((value ?: 0) - 1).coerceIn(range)
if (decrementedValue != value) {
onValueChange(decrementedValue)
}
},
isEnabled = isDecrementEnabled,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
val incrementedValue = ((value ?: 0) + 1).coerceIn(range)
if (incrementedValue != value) {
onValueChange(incrementedValue)
}
},
isEnabled = isIncrementEnabled,
)
},
modifier = modifier,
)
}

View file

@ -50,10 +50,15 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MAX_NUMBER_OF_WORDS
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MIN_NUMBER_OF_WORDS
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MAX
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MIN
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MIN
@ -472,29 +477,11 @@ private fun PasswordMinNumbersCounterItem(
minNumbers: Int,
onPasswordMinNumbersCounterChange: (Int) -> Unit,
) {
BitwardenReadOnlyTextFieldWithActions(
BitwardenStepper(
label = stringResource(id = R.string.min_numbers),
value = minNumbers.toString(),
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
onPasswordMinNumbersCounterChange(minNumbers - 1)
},
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPasswordMinNumbersCounterChange(minNumbers + 1)
},
)
},
value = minNumbers,
range = PASSWORD_COUNTER_MIN..PASSWORD_COUNTER_MAX,
onValueChange = onPasswordMinNumbersCounterChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
@ -504,29 +491,11 @@ private fun PasswordMinSpecialCharactersCounterItem(
minSpecial: Int,
onPasswordMinSpecialCharactersChange: (Int) -> Unit,
) {
BitwardenReadOnlyTextFieldWithActions(
BitwardenStepper(
label = stringResource(id = R.string.min_special),
value = minSpecial.toString(),
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
onPasswordMinSpecialCharactersChange(minSpecial - 1)
},
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPasswordMinSpecialCharactersChange(minSpecial + 1)
},
)
},
value = minSpecial,
range = PASSWORD_COUNTER_MIN..PASSWORD_COUNTER_MAX,
onValueChange = onPasswordMinSpecialCharactersChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
@ -586,29 +555,11 @@ private fun PassphraseNumWordsCounterItem(
numWords: Int,
onPassphraseNumWordsCounterChange: (Int) -> Unit,
) {
BitwardenReadOnlyTextFieldWithActions(
BitwardenStepper(
label = stringResource(id = R.string.number_of_words),
value = numWords.toString(),
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
onPassphraseNumWordsCounterChange(numWords - 1)
},
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPassphraseNumWordsCounterChange(numWords + 1)
},
)
},
value = numWords,
range = PASSPHRASE_MIN_NUMBER_OF_WORDS..PASSPHRASE_MAX_NUMBER_OF_WORDS,
onValueChange = onPassphraseNumWordsCounterChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
}

View file

@ -9,11 +9,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MAX_NUMBER_OF_WORDS
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MIN_NUMBER_OF_WORDS
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MAX
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MIN
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeTypeOption
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.AnonAddy
@ -24,8 +20,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.lang.Integer.max
import java.lang.Integer.min
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -266,24 +260,16 @@ class GeneratorViewModel @Inject constructor(
private fun handleMinNumbersChange(
action: GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange,
) {
val adjustedMinNumbers = action
.minNumbers
.coerceIn(PASSWORD_COUNTER_MIN, PASSWORD_COUNTER_MAX)
updatePasswordType { currentPasswordType ->
currentPasswordType.copy(minNumbers = adjustedMinNumbers)
currentPasswordType.copy(minNumbers = action.minNumbers)
}
}
private fun handleMinSpecialChange(
action: GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange,
) {
val adjustedMinSpecial = action
.minSpecial
.coerceIn(PASSWORD_COUNTER_MIN, PASSWORD_COUNTER_MAX)
updatePasswordType { currentPasswordType ->
currentPasswordType.copy(minSpecial = adjustedMinSpecial)
currentPasswordType.copy(minSpecial = action.minSpecial)
}
}
@ -352,11 +338,7 @@ class GeneratorViewModel @Inject constructor(
action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange,
) {
updatePassphraseType { passphraseType ->
val newNumWords = max(
PASSPHRASE_MIN_NUMBER_OF_WORDS,
min(PASSPHRASE_MAX_NUMBER_OF_WORDS, action.numWords),
)
passphraseType.copy(numWords = newNumWords)
passphraseType.copy(numWords = action.numWords)
}
}

View file

@ -43,19 +43,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.tools.feature.send.NewSendAction.MaxAccessCountChange
/**
* Displays new send UX.
@ -211,11 +208,8 @@ fun NewSendScreen(
Spacer(modifier = Modifier.height(16.dp))
NewSendOptions(
state = state,
onIncrementMaxAccessCountClick = remember(viewModel) {
{ viewModel.trySendAction(MaxAccessCountChange(it)) }
},
onDecrementMaxAccessCountClick = remember(viewModel) {
{ viewModel.trySendAction(MaxAccessCountChange(it)) }
onMaxAccessCountChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.MaxAccessCountChange(it)) }
},
onPasswordChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.PasswordChange(it)) }
@ -238,8 +232,7 @@ fun NewSendScreen(
* Displays a collapsable set of new send options.
*
* @param state state.
* @param onIncrementMaxAccessCountClick called when increment max access count is clicked.
* @param onDecrementMaxAccessCountClick called when decrement max access count is clicked.
* @param onMaxAccessCountChange called when max access count changes.
* @param onPasswordChange called when the password changes.
* @param onNoteChange called when the notes changes.
* @param onHideEmailChecked called when hide email is checked.
@ -249,8 +242,7 @@ fun NewSendScreen(
@Composable
private fun NewSendOptions(
state: NewSendState,
onIncrementMaxAccessCountClick: (Int) -> Unit,
onDecrementMaxAccessCountClick: (Int) -> Unit,
onMaxAccessCountChange: (Int) -> Unit,
onPasswordChange: (String) -> Unit,
onNoteChange: (String) -> Unit,
onHideEmailChecked: (Boolean) -> Unit,
@ -300,33 +292,13 @@ private fun NewSendOptions(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenReadOnlyTextFieldWithActions(
label = stringResource(R.string.maximum_access_count),
// we use a space instead of empty string to make sure label is shown small and
// above the input
value = state.maxAccessCount?.toString() ?: " ",
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
onIncrementMaxAccessCountClick.invoke((state.maxAccessCount ?: 0) - 1)
},
isEnabled = state.maxAccessCount != null,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onDecrementMaxAccessCountClick.invoke((state.maxAccessCount ?: 0) + 1)
},
)
},
modifier = Modifier.padding(horizontal = 16.dp),
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
value = state.maxAccessCount,
onValueChange = onMaxAccessCountChange,
isDecrementEnabled = state.maxAccessCount != null,
modifier = Modifier
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(

View file

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import kotlin.math.max
private const val KEY_STATE = "state"
@ -127,7 +126,7 @@ class NewSendViewModel @Inject constructor(
private fun handleMaxAccessCountChange(action: NewSendAction.MaxAccessCountChange) {
mutableStateFlow.update {
it.copy(maxAccessCount = max(1, action.newValue))
it.copy(maxAccessCount = action.value)
}
}
}
@ -239,9 +238,9 @@ sealed class NewSendAction {
data class HideByDefaultToggle(val isChecked: Boolean) : NewSendAction()
/**
* User incremented or decremented the max access count.
* User incremented the max access count.
*/
data class MaxAccessCountChange(val newValue: Int) : NewSendAction()
data class MaxAccessCountChange(val value: Int) : NewSendAction()
/**
* User toggled the "hide my email" toggle.

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Test
@Suppress("LargeClass")
class GeneratorScreenTest : BaseComposeTest() {
private val mutableStateFlow = MutableStateFlow(
GeneratorState(
@ -409,6 +410,70 @@ class GeneratorScreenTest : BaseComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, decrementing the minimum numbers counter below 0 should do nothing`() {
val initialMinNumbers = 0
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Password(minNumbers = initialMinNumbers),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
.onChildren()
.filterToOne(hasContentDescription("\u2212"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, incrementing the minimum numbers counter above 5 should do nothing`() {
val initialMinNumbers = 5
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Password(minNumbers = initialMinNumbers),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum numbers, $initialMinNumbers")
.onChildren()
.filterToOne(hasContentDescription("+"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, decrementing the minimum special characters counter should send MinSpecialCharactersChange action`() {
@ -485,6 +550,70 @@ class GeneratorScreenTest : BaseComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, decrementing the minimum special characters below 0 should do nothing`() {
val initialSpecialChars = 0
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Password(minSpecial = initialSpecialChars),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
.onChildren()
.filterToOne(hasContentDescription("\u2212"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, decrementing the minimum special characters above 5 should do nothing`() {
val initialSpecialChars = 5
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Password(minSpecial = initialSpecialChars),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Minimum special, $initialSpecialChars")
.onChildren()
.filterToOne(hasContentDescription("+"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Password state, toggling the use avoid ambiguous characters toggle should send ToggleSpecialCharactersChange action`() {
@ -517,7 +646,7 @@ class GeneratorScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Passphrase state, decrementing number of words should send NumWordsCounterChange action with decremented value`() {
val initialNumWords = 3
val initialNumWords = 4
updateState(
GeneratorState(
generatedText = "Placeholder",
@ -528,7 +657,7 @@ class GeneratorScreenTest : BaseComposeTest() {
.MainType
.Passcode
.PasscodeType
.Passphrase(),
.Passphrase(numWords = initialNumWords),
),
),
)
@ -539,7 +668,7 @@ class GeneratorScreenTest : BaseComposeTest() {
// Unicode for "minus" used for content description
composeTestRule
.onNodeWithContentDescription("Number of words, 3")
.onNodeWithContentDescription("Number of words, $initialNumWords")
.onChildren()
.filterToOne(hasContentDescription("\u2212"))
.performScrollTo()
@ -554,6 +683,70 @@ class GeneratorScreenTest : BaseComposeTest() {
}
}
@Test
fun `in Passcode_Passphrase state, decrementing number of words under 3 should do nothing`() {
val initialNumWords = 3
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Passphrase(numWords = initialNumWords),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Unicode for "minus" used for content description
composeTestRule
.onNodeWithContentDescription("Number of words, $initialNumWords")
.onChildren()
.filterToOne(hasContentDescription("\u2212"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Test
fun `in Passcode_Passphrase state, incrementing number of words over 20 should do nothing`() {
val initialNumWords = 20
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState
.MainType
.Passcode(
GeneratorState
.MainType
.Passcode
.PasscodeType
.Passphrase(numWords = initialNumWords),
),
),
)
composeTestRule.setContent {
GeneratorScreen(viewModel = viewModel)
}
// Unicode for "minus" used for content description
composeTestRule
.onNodeWithContentDescription("Number of words, $initialNumWords")
.onChildren()
.filterToOne(hasContentDescription("+"))
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `in Passcode_Passphrase state, incrementing number of words should send NumWordsCounterChange action with incremented value`() {

View file

@ -248,6 +248,25 @@ class NewSendScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(NewSendAction.MaxAccessCountChange(2)) }
}
@Test
fun `max access count decrement when set to 1 should do nothing`() =
runTest {
mutableStateFlow.update {
it.copy(maxAccessCount = 1)
}
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithContentDescription("\u2212")
.performScrollTo()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
}
@Test
fun `on max access count increment should send MaxAccessCountChange`() = runTest {
// Expand options section:

View file

@ -87,16 +87,6 @@ class NewSendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `MaxAccessCountChang below 1 should keep maxAccessCount at 1`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.MaxAccessCountChange(0))
assertEquals(DEFAULT_STATE.copy(maxAccessCount = 1), awaitItem())
}
}
@Test
fun `TextChange should update text input`() = runTest {
val viewModel = createViewModel()