BIT-333: Handle Passphrase state in GeneratorViewModel (#64)

This commit is contained in:
joshua-livefront 2023-09-25 15:12:26 -04:00 committed by Álison Fernandes
parent 5a05988d16
commit 23a053edb8
3 changed files with 1182 additions and 97 deletions

View file

@ -1,5 +1,8 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.tools.feature.generator
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -8,9 +11,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
@ -18,19 +23,23 @@ import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -39,40 +48,99 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Top level composable for the generator screen.
*/
@Composable
fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit = {
viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(it))
}
val onPasscodeOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit = {
viewModel.trySendAction(GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect(it))
}
val onNumWordsCounterChange: (Int) -> Unit = { changeInCounter ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange(
numWords = changeInCounter,
),
)
}
val onPassphraseCapitalizeToggleChange: (Boolean) -> Unit = { shouldCapitalize ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange(
capitalize = shouldCapitalize,
),
)
}
val onIncludeNumberToggleChange: (Boolean) -> Unit = { shouldIncludeNumber ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange(
includeNumber = shouldIncludeNumber,
),
)
}
val onWordSeparatorChange: (Char?) -> Unit = { newSeparator ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange(
wordSeparator = newSeparator,
),
)
}
Scaffold(
topBar = { TopAppBar() },
) { innerPadding ->
ScrollContent(
state,
onMainStateOptionClicked,
onPasscodeOptionClicked,
onNumWordsCounterChange,
onWordSeparatorChange,
onPassphraseCapitalizeToggleChange,
onIncludeNumberToggleChange,
Modifier.padding(innerPadding),
)
}
}
//region TopAppBar Composables
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeneratorScreen() {
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
),
title = {
Text(
text = stringResource(id = R.string.generator),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
},
navigationIcon = {
Spacer(Modifier.width(40.dp))
},
actions = {
OverflowMenu()
},
private fun TopAppBar() {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
),
title = {
Text(
stringResource(id = R.string.generator),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
},
) { innerPadding ->
ScrollContent(modifier = Modifier.padding(innerPadding))
}
navigationIcon = {
Spacer(Modifier.width(40.dp))
},
actions = {
OverflowMenu()
},
)
}
@Composable
@ -88,36 +156,61 @@ private fun OverflowMenu() {
}
}
//endregion TopAppBar Composables
//region ScrollContent and Static Items
@Composable
private fun ScrollContent(modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier.fillMaxSize()) {
item { DynamicStringItem() }
item { TextItem(title = stringResource(id = R.string.what_would_you_like_to_generate)) }
item { TextItem(title = stringResource(id = R.string.password_type), showOptions = true) }
item { LengthSliderItem() }
item { ToggleItem(stringResource(id = R.string.uppercase_ato_z)) }
item { ToggleItem(stringResource(id = R.string.lowercase_ato_z)) }
item { ToggleItem(stringResource(id = R.string.numbers_zero_to_nine)) }
item { ToggleItem(stringResource(id = R.string.special_characters)) }
item { CounterItem(label = stringResource(id = R.string.min_numbers)) }
item { CounterItem(label = stringResource(id = R.string.min_special)) }
item { ToggleItem(stringResource(id = R.string.avoid_ambiguous_characters)) }
private fun ScrollContent(
state: GeneratorState,
onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit,
onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit,
onNumWordsCounterChange: (Int) -> Unit,
onWordSeparatorChange: (Char?) -> Unit,
onCapitalizeToggleChange: (Boolean) -> Unit,
onIncludeNumberToggleChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
GeneratedStringItem(state.generatedText)
MainStateOptionsItem(
selectedType = state.selectedType,
possibleMainStates = state.typeOptions,
onMainStateOptionClicked = onMainStateOptionClicked,
)
when (val selectedType = state.selectedType) {
is GeneratorState.MainType.Passcode -> {
PasscodeTypeItems(
selectedType,
onSubStateOptionClicked,
onNumWordsCounterChange,
onWordSeparatorChange,
onCapitalizeToggleChange,
onIncludeNumberToggleChange,
)
}
is GeneratorState.MainType.Username -> {
// TODO(BIT-335): Username state to handle Plus Addressed Email
}
}
}
}
@Composable
private fun DynamicStringItem() {
// TODO(BIT-276): Move this state to ViewModel
val placeholderPassword = "PLACEHOLDER"
val dynamicString = remember { mutableStateOf(placeholderPassword) }
private fun GeneratedStringItem(generatedText: String) {
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = dynamicString.value)
Text(text = generatedText)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -147,10 +240,204 @@ private fun DynamicStringItem() {
}
@Composable
private fun TextItem(title: String, showOptions: Boolean = false) {
// TODO(BIT-276): Move this state to ViewModel
val defaultType = stringResource(id = R.string.password)
val content = remember { mutableStateOf(defaultType) }
private fun MainStateOptionsItem(
selectedType: GeneratorState.MainType,
possibleMainStates: List<GeneratorState.MainTypeOption>,
onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit,
) {
val optionsWithStrings =
possibleMainStates.associateBy({ it }, { stringResource(id = it.labelRes) })
OptionsSelectionItem(
title = stringResource(id = R.string.what_would_you_like_to_generate),
showOptionsText = false,
options = optionsWithStrings.values.toList(),
selectedOption = stringResource(id = selectedType.displayStringResId),
onOptionSelected = { selectedOption ->
val selectedOptionId =
optionsWithStrings.entries.first { it.value == selectedOption }.key
onMainStateOptionClicked(selectedOptionId)
},
)
}
//endregion ScrollContent and Static Items
//region PasscodeType Composables
/**
* A composable function to represent a collection of passcode type items based on the selected
* [GeneratorState.MainType.Passcode.PasscodeType]. It dynamically displays content depending on
* the currently selected passcode type.
*
* @param passcodeState The current state of the passcode generator,
* holding the selected passcode type and other settings.
* @param onSubStateOptionClicked A lambda function invoked when a substate option is clicked.
* It takes the selected [GeneratorState.MainType.Passcode.PasscodeTypeOption] as a parameter.
* @param onNumWordsCounterChange A lambda function invoked when there is a change
* in the number of words for passphrase. It takes the updated number of words as a parameter.
* @param onWordSeparatorChange A lambda function invoked when there is a change
* in the word separator character for passphrase. It takes the updated character as a parameter,
* `null` if there is no separator.
* @param onCapitalizeToggleChange A lambda function invoked when the capitalize
* toggle state changes for passphrase. It takes the updated toggle state as a parameter.
* @param onIncludeNumberToggleChange A lambda function invoked when the include number toggle
* state changes for passphrase. It takes the updated toggle state as a parameter.
*/
@Composable
fun PasscodeTypeItems(
passcodeState: GeneratorState.MainType.Passcode,
onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit,
onNumWordsCounterChange: (Int) -> Unit,
onWordSeparatorChange: (Char?) -> Unit,
onCapitalizeToggleChange: (Boolean) -> Unit,
onIncludeNumberToggleChange: (Boolean) -> Unit,
) {
PasscodeOptionsItem(passcodeState, onSubStateOptionClicked)
when (val selectedType = passcodeState.selectedType) {
is GeneratorState.MainType.Passcode.PasscodeType.Passphrase -> {
PassphraseTypeContent(
selectedType,
onNumWordsCounterChange,
onWordSeparatorChange,
onCapitalizeToggleChange,
onIncludeNumberToggleChange,
)
}
is GeneratorState.MainType.Passcode.PasscodeType.Password -> {
// TODO(BIT-334): Render UI for Password type
}
}
}
@Composable
private fun PasscodeOptionsItem(
currentSubState: GeneratorState.MainType.Passcode,
onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit,
) {
val possibleSubStates = GeneratorState.MainType.Passcode.PasscodeTypeOption.values().toList()
val optionsWithStrings =
possibleSubStates.associateBy({ it }, { stringResource(id = it.labelRes) })
OptionsSelectionItem(
title = stringResource(id = currentSubState.selectedType.displayStringResId),
showOptionsText = true,
options = optionsWithStrings.values.toList(),
selectedOption = stringResource(id = currentSubState.selectedType.displayStringResId),
onOptionSelected = { selectedOption ->
val selectedOptionId =
optionsWithStrings.entries.first { it.value == selectedOption }.key
onSubStateOptionClicked(selectedOptionId)
},
)
}
//endregion PasscodeType Composables
//region PassphraseType Composables
@Composable
private fun PassphraseTypeContent(
passphraseTypeState: GeneratorState.MainType.Passcode.PasscodeType.Passphrase,
onNumWordsCounterChange: (Int) -> Unit,
onWordSeparatorChange: (Char?) -> Unit,
onCapitalizeToggleChange: (Boolean) -> Unit,
onIncludeNumberToggleChange: (Boolean) -> Unit,
) {
PassphraseNumWordsCounterItem(
numWords = passphraseTypeState.numWords,
onNumWordsCounterChange = onNumWordsCounterChange,
)
PassphraseWordSeparatorInputItem(
wordSeparator = passphraseTypeState.wordSeparator,
onWordSeparatorChange = onWordSeparatorChange,
)
PassphraseCapitalizeToggleItem(
capitalize = passphraseTypeState.capitalize,
onPassphraseCapitalizeToggleChange = onCapitalizeToggleChange,
)
PassphraseIncludeNumberToggleItem(
includeNumber = passphraseTypeState.includeNumber,
onIncludeNumberToggleChange = onIncludeNumberToggleChange,
)
}
@Composable
private fun PassphraseNumWordsCounterItem(
numWords: Int,
onNumWordsCounterChange: (Int) -> Unit,
) {
CounterItem(
label = stringResource(id = R.string.number_of_words),
counter = numWords,
counterValueChange = onNumWordsCounterChange,
)
}
@Composable
private fun PassphraseWordSeparatorInputItem(
wordSeparator: Char?,
onWordSeparatorChange: (wordSeparator: Char?) -> Unit,
) {
TextInputItem(
title = stringResource(id = R.string.word_separator),
defaultText = wordSeparator?.toString() ?: "",
textInputChange = {
onWordSeparatorChange(it.toCharArray().firstOrNull())
},
)
}
@Composable
private fun PassphraseCapitalizeToggleItem(
capitalize: Boolean,
onPassphraseCapitalizeToggleChange: (Boolean) -> Unit,
) {
SwitchItem(
title = stringResource(id = R.string.capitalize),
isToggled = capitalize,
onToggleChange = onPassphraseCapitalizeToggleChange,
)
}
@Composable
private fun PassphraseIncludeNumberToggleItem(
includeNumber: Boolean,
onIncludeNumberToggleChange: (Boolean) -> Unit,
) {
SwitchItem(
title = stringResource(id = R.string.include_number),
isToggled = includeNumber,
onToggleChange = onIncludeNumberToggleChange,
)
}
//endregion PassphraseType Composables
//region Generic Control Composables
/**
* This composable function renders an item for selecting options, with a capability
* to expand and collapse the options. It also optionally displays a text indicating
* that there are multiple options to choose from.
*
* @param title The title of the item. This string will be displayed above the selected option.
* @param showOptionsText A boolean flag that determines whether to show the Options header text.
* @param options A list of strings representing the available options for selection.
* @param selectedOption The currently selected option. This will be displayed on the item.
* @param onOptionSelected A callback invoked when an option is selected, passing the selected
*/
@Composable
private fun OptionsSelectionItem(
title: String,
showOptionsText: Boolean = false,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
CommonPadding {
Column(
@ -159,7 +446,7 @@ private fun TextItem(title: String, showOptions: Boolean = false) {
.padding(top = 4.dp, bottom = 4.dp),
verticalArrangement = Arrangement.Center,
) {
if (showOptions) {
if (showOptionsText) {
Text(
stringResource(id = R.string.options),
style = TextStyle(fontSize = 12.sp),
@ -167,57 +454,47 @@ private fun TextItem(title: String, showOptions: Boolean = false) {
)
}
Text(title, style = TextStyle(fontSize = 10.sp))
Text(content.value)
}
}
}
@Composable
private fun LengthSliderItem() {
// TODO(BIT-276): Move this state to ViewModel
val sliderPosition = remember { mutableStateOf(0f) }
CommonPadding {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(id = R.string.length))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Spacer(modifier = Modifier.width(16.dp))
Text(sliderPosition.value.toInt().toString())
Slider(
value = sliderPosition.value,
onValueChange = {},
)
Box(modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }) {
Text(selectedOption)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { optionString ->
DropdownMenuItem(
text = { Text(text = optionString) },
onClick = {
expanded = false
onOptionSelected(optionString)
},
)
}
}
}
}
}
}
/**
* A composable function to represent a counter item, which consists of a label,
* decrement button, an increment button, and display of the current counter.
*
* @param label The text to be displayed as a label for the counter item.
* @param counter The current value of the counter.
* @param counterValueChange A lambda function invoked when there is a change in the counter value.
* It takes the updated counter value as a parameter.
*/
@Composable
private fun ToggleItem(title: String) {
// TODO(BIT-276): Move this state to ViewModel
val isToggled = remember { mutableStateOf(false) }
CommonPadding {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title)
Switch(checked = isToggled.value, onCheckedChange = { isToggled.value = it })
}
}
}
@Composable
private fun CounterItem(label: String) {
// TODO(BIT-276): Move this state to ViewModel
val counter = remember { mutableStateOf(1) }
private fun CounterItem(
label: String,
counter: Int,
counterValueChange: (Int) -> Unit,
) {
CommonPadding {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@ -228,9 +505,8 @@ private fun CounterItem(label: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(counter.value.toString())
IconButton(
onClick = {},
onClick = { counterValueChange(counter - 1) },
) {
Icon(
Icons.Default.ArrowBack,
@ -238,8 +514,11 @@ private fun CounterItem(label: String) {
tint = MaterialTheme.colorScheme.primary,
)
}
Text(counter.toString())
IconButton(
onClick = {},
onClick = { counterValueChange(counter + 1) },
) {
Icon(
Icons.Default.ArrowForward,
@ -252,6 +531,86 @@ private fun CounterItem(label: String) {
}
}
/**
* A composable function to represent a text input item, which consists of a title,
* an optional context text above the title, and a text field for input.
*
* @param title The title of the text input item.
* @param defaultText The default text displayed in the text field.
* @param textInputChange A lambda function invoked when there is a change in the text field value.
* It takes the updated text value as a parameter.
* @param contextText The optional context text displayed above the title.
* @param maxLines The maximum number of lines for the text field.
*/
@Composable
private fun TextInputItem(
title: String,
defaultText: String,
textInputChange: (String) -> Unit,
contextText: String? = null,
maxLines: Int = 1,
) {
CommonPadding {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(top = 4.dp, bottom = 4.dp),
verticalArrangement = Arrangement.Center,
) {
contextText?.let {
Text(it, style = TextStyle(fontSize = 10.sp))
}
if (contextText != null) {
Spacer(modifier = Modifier.height(4.dp))
}
Text(title, style = TextStyle(fontSize = 10.sp))
Box(modifier = Modifier.fillMaxWidth()) {
TextField(
value = defaultText,
onValueChange = { newValue ->
textInputChange(newValue)
},
maxLines = maxLines,
)
}
}
}
}
/**
* A composable function to represent a switch item, which consists of a title and a switch.
*
* @param title The title of the switch item.
* @param isToggled The current state of the switch; whether it's toggled on or off.
* @param onToggleChange A lambda function invoked when there is a change in the switch state.
* It takes the updated switch state as a parameter.
*/
@Composable
private fun SwitchItem(
title: String,
isToggled: Boolean,
onToggleChange: (Boolean) -> Unit,
) {
CommonPadding {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title)
Switch(
checked = isToggled,
onCheckedChange = onToggleChange,
)
}
}
}
//endregion Generic Control Composables
@Composable
private fun CommonPadding(content: @Composable () -> Unit) {
Box(

View file

@ -0,0 +1,487 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.tools.feature.generator
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
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.PasscodeTypeOption
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
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"
/**
* ViewModel responsible for handling user interactions in the generator screen.
*
* This ViewModel processes UI actions, manages the state of the generator screen,
* and provides data for the UI to render. It extends a `BaseViewModel` and works
* with a `SavedStateHandle` for state restoration.
*
* @property savedStateHandle Handles the saved state of this ViewModel.
*/
@HiltViewModel
class GeneratorViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<GeneratorState, GeneratorEvent, GeneratorAction>(
initialState = savedStateHandle[KEY_STATE] ?: INITIAL_STATE,
) {
//region Initialization and Overrides
init {
viewModelScope.launch {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
}
override fun handleAction(action: GeneratorAction) {
when (action) {
is GeneratorAction.MainTypeOptionSelect -> {
handleMainTypeOptionSelect(action)
}
is GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect -> {
handlePasscodeTypeOptionSelect(action)
}
is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase -> {
handlePassphraseSpecificAction(action)
}
}
}
//endregion Initialization and Overrides
//region Main Type Option Handlers
private fun handleMainTypeOptionSelect(action: GeneratorAction.MainTypeOptionSelect) {
when (action.mainTypeOption) {
GeneratorState.MainTypeOption.PASSWORD -> handleSwitchToPasscode()
GeneratorState.MainTypeOption.USERNAME -> handleSwitchToUsername()
}
}
private fun handleSwitchToPasscode() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = Passcode(),
)
}
}
private fun handleSwitchToUsername() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = GeneratorState.MainType.Username,
)
}
}
//endregion Main Type Option Handlers
//region Passcode Type Handlers
private fun handlePasscodeTypeOptionSelect(
action: GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect,
) {
when (action.passcodeTypeOption) {
PasscodeTypeOption.PASSWORD -> handleSwitchToPasswordType()
PasscodeTypeOption.PASSPHRASE -> handleSwitchToPassphraseType()
}
}
private fun handleSwitchToPasswordType() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = Passcode(
selectedType = Password(),
),
)
}
}
private fun handleSwitchToPassphraseType() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = Passcode(
selectedType = Passphrase(),
),
)
}
}
//endregion Passcode Type Handlers
//region Passphrase Specific Handlers
private fun handlePassphraseSpecificAction(
action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase,
) {
when (action) {
is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange,
-> {
handleNumWordsCounterChange(action)
}
is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange,
-> {
handleToggleCapitalizeChange(action)
}
is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange,
-> {
handleToggleIncludeNumberChange(action)
}
is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange,
-> {
handleWordSeparatorTextInputChange(action)
}
}
}
private fun handleToggleCapitalizeChange(
action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange,
) {
updatePassphraseType { currentPassphraseType ->
currentPassphraseType.copy(
capitalize = action.capitalize,
)
}
}
private fun handleToggleIncludeNumberChange(
action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange,
) {
updatePassphraseType { currentPassphraseType ->
currentPassphraseType.copy(
includeNumber = action.includeNumber,
)
}
}
private fun handleNumWordsCounterChange(
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)
}
}
private fun handleWordSeparatorTextInputChange(
action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange,
) {
updatePassphraseType { passphraseType ->
val newWordSeparator =
action.wordSeparator
passphraseType.copy(wordSeparator = newWordSeparator)
}
}
//endregion Passphrase Specific Handlers
//region Utility Functions
private inline fun updateGeneratorMainTypePassword(
crossinline block: (Passcode) -> Passcode,
) {
mutableStateFlow.update { currentState ->
val currentSelectedType = currentState.selectedType
if (currentSelectedType !is Passcode) return@update currentState
val updatedPasscode = block(currentSelectedType)
// TODO(BIT-277): Replace placeholder text with function to generate new text
val newText = currentState.generatedText.reversed()
currentState.copy(selectedType = updatedPasscode, generatedText = newText)
}
}
private inline fun updatePassphraseType(
crossinline block: (Passphrase) -> Passphrase,
) {
updateGeneratorMainTypePassword { currentSelectedType ->
val currentPasswordType = currentSelectedType.selectedType
if (currentPasswordType !is Passphrase) {
return@updateGeneratorMainTypePassword currentSelectedType
}
currentSelectedType.copy(selectedType = block(currentPasswordType))
}
}
//endregion Utility Functions
companion object {
private const val PLACEHOLDER_GENERATED_TEXT = "Placeholder"
val INITIAL_STATE: GeneratorState = GeneratorState(
generatedText = PLACEHOLDER_GENERATED_TEXT,
selectedType = Passcode(
selectedType = Password(),
),
)
}
}
/**
* Represents the state of the generator, maintaining the generated text and the
* selected type along with the options for the type of generation (PASSWORD, USERNAME).
*
* @param generatedText The text that is generated based on the selected options.
* @param selectedType The currently selected main type for generating text.
*/
@Parcelize
data class GeneratorState(
val generatedText: String,
val selectedType: MainType,
) : Parcelable {
/**
* Provides a list of available main types for the generator.
*/
val typeOptions: List<MainTypeOption>
get() = MainTypeOption.values().toList()
/**
* Enum representing the main type options for the generator, such as PASSWORD and USERNAME.
*
* @property labelRes The resource ID of the string that represents the label of each type.
*/
enum class MainTypeOption(val labelRes: Int) {
PASSWORD(R.string.password),
USERNAME(R.string.username),
}
/**
* A sealed class representing the main types that can be selected in the generator,
* encapsulating the different configurations and properties each main type has.
*/
@Parcelize
sealed class MainType : Parcelable {
/**
* Represents the resource ID for the display string. This is an abstract property
* that must be overridden by each subclass to provide the appropriate string resource ID
* for display purposes.
*/
abstract val displayStringResId: Int
/**
* Represents the Passcode main type, allowing the user to specify the kind of passcode,
* such as a Password or a Passphrase, and configure their respective properties.
*
* @property selectedType The currently selected PasscodeType
*/
@Parcelize
data class Passcode(
val selectedType: PasscodeType = Password(),
) : MainType(), Parcelable {
override val displayStringResId: Int
get() = MainTypeOption.PASSWORD.labelRes
/**
* Enum representing the types of passcodes,
* allowing for different passcode configurations.
*
* @property labelRes The ID of the string that represents the label for each type.
*/
enum class PasscodeTypeOption(val labelRes: Int) {
PASSWORD(R.string.password),
PASSPHRASE(R.string.passphrase),
}
/**
* A sealed class representing the different types of PASSWORD,
* such as standard Password and Passphrase, each with its own properties.
*/
@Parcelize
sealed class PasscodeType : Parcelable {
/**
* Represents the resource ID for the display string specific to each
* PasscodeType subclass. Every subclass of PasscodeType must override
* this property to provide the appropriate string resource ID for
* its display string.
*/
abstract val displayStringResId: Int
/**
* Represents a standard PASSWORD type, with a specified length.
*
* @property length The length of the generated password.
*/
@Parcelize
data class Password(
val length: Int = DEFAULT_PASSWORD_LENGTH,
) : PasscodeType(), Parcelable {
override val displayStringResId: Int
get() = PasscodeTypeOption.PASSWORD.labelRes
companion object {
const val DEFAULT_PASSWORD_LENGTH: Int = 10
}
}
/**
* Represents a Passphrase type, configured with number of words, word separator,
* capitalization, and inclusion of numbers.
*
* @property numWords The number of words in the passphrase.
* @property wordSeparator The character used to separate words in the passphrase.
* @property capitalize Whether to capitalize the first letter of each word.
* @property includeNumber Whether to include a numbers in the passphrase.
*/
@Parcelize
data class Passphrase(
val numWords: Int = DEFAULT_NUM_WORDS,
val wordSeparator: Char? = DEFAULT_PASSPHRASE_SEPARATOR,
val capitalize: Boolean = false,
val includeNumber: Boolean = false,
) : PasscodeType(), Parcelable {
override val displayStringResId: Int
get() = PasscodeTypeOption.PASSPHRASE.labelRes
companion object {
private const val DEFAULT_PASSPHRASE_SEPARATOR: Char = '-'
private const val DEFAULT_NUM_WORDS: Int = 3
const val PASSPHRASE_MIN_NUMBER_OF_WORDS: Int = 3
const val PASSPHRASE_MAX_NUMBER_OF_WORDS: Int = 20
}
}
}
}
/**
* Represents the USERNAME main type, holding the configuration
* and properties for generating usernames.
*/
@Parcelize
data object Username : MainType() {
override val displayStringResId: Int
get() = MainTypeOption.USERNAME.labelRes
}
}
}
/**
* Represents an action in the generator feature.
*
* This sealed class serves as a type for defining all the possible actions within
* the generator feature, ensuring type safety and clear, structured action definitions.
*/
sealed class GeneratorAction {
/**
* Represents the action of selecting a main type option.
*
* @property mainTypeOption The selected main type option.
*/
data class MainTypeOptionSelect(
val mainTypeOption: GeneratorState.MainTypeOption,
) : GeneratorAction()
/**
* Represents actions related to the [GeneratorState.MainType] in the generator feature.
*/
sealed class MainType : GeneratorAction() {
/**
* Represents actions specifically related to [GeneratorState.MainType.Passcode].
*/
sealed class Passcode : MainType() {
/**
* Represents the action of selecting a passcode type option.
*
* @property passcodeTypeOption The selected passcode type option.
*/
data class PasscodeTypeOptionSelect(
val passcodeTypeOption: PasscodeTypeOption,
) : Passcode()
/**
* Represents actions related to the different types of passcodes.
*/
sealed class PasscodeType : Passcode() {
/**
* Represents actions specifically related to passwords, a subtype of passcode.
*/
sealed class Password : PasscodeType()
/**
* Represents actions specifically related to passphrases, a subtype of passcode.
*/
sealed class Passphrase : PasscodeType() {
/**
* Fired when the number of words counter is changed.
*
* @property numWords The new value for the number of words.
*/
data class NumWordsCounterChange(val numWords: Int) : Passphrase()
/**
* Fired when the word separator text input is changed.
*
* @property wordSeparator The new word separator text.
*/
data class WordSeparatorTextChange(val wordSeparator: Char?) : Passphrase()
/**
* Fired when the "capitalize" toggle is changed.
*
* @property capitalize The new value of the "capitalize" toggle.
*/
data class ToggleCapitalizeChange(val capitalize: Boolean) : Passphrase()
/**
* Fired when the "include number" toggle is changed.
*
* @property includeNumber The new value of the "include number" toggle.
*/
data class ToggleIncludeNumberChange(val includeNumber: Boolean) : Passphrase()
}
}
}
/**
* Represents actions related to the [GeneratorState.MainType.Username] in the generator.
*
* This sealed class serves as a placeholder for future extensions
* related to the username actions in the generator.
*/
sealed class Username : MainType()
}
}
/**
* Sealed class representing the different types of UI events that can be triggered.
*
* These events are meant to represent various types of user interactions within
* the generator screen.
*/
sealed class GeneratorEvent {
// TODO(BIT-317): Setup data objects to represent UI events that can be triggered
}

View file

@ -0,0 +1,239 @@
@file:Suppress("MaxLineLength")
package com.x8bit.bitwarden.ui.tools.feature.generator
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class GeneratorViewModelTest : BaseViewModelTest() {
private val initialState = createPasswordState()
private val initialSavedStateHandle = createSavedStateHandleWithState(initialState)
@Test
fun `initial state should be correct`() = runTest {
val viewModel = GeneratorViewModel(initialSavedStateHandle)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
}
}
@Test
fun `MainTypeOptionSelect PASSWORD should switch to Passcode`() = runTest {
val viewModel = GeneratorViewModel(initialSavedStateHandle)
val action = GeneratorAction.MainTypeOptionSelect(GeneratorState.MainTypeOption.PASSWORD)
viewModel.actionChannel.trySend(action)
val expectedState =
initialState.copy(selectedType = GeneratorState.MainType.Passcode())
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `MainTypeOptionSelect USERNAME should switch to Username`() = runTest {
val viewModel = GeneratorViewModel(initialSavedStateHandle)
val action = GeneratorAction.MainTypeOptionSelect(GeneratorState.MainTypeOption.USERNAME)
viewModel.actionChannel.trySend(action)
val expectedState = initialState.copy(selectedType = GeneratorState.MainType.Username)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `PasscodeTypeOptionSelect PASSWORD should switch to PasswordType`() = runTest {
val viewModel = GeneratorViewModel(initialSavedStateHandle)
val action = GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect(
passcodeTypeOption = GeneratorState.MainType.Passcode.PasscodeTypeOption.PASSWORD,
)
viewModel.actionChannel.trySend(action)
val expectedState = initialState.copy(
selectedType = GeneratorState.MainType.Passcode(
selectedType = GeneratorState.MainType.Passcode.PasscodeType.Password(),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `PasscodeTypeOptionSelect PASSPHRASE should switch to PassphraseType`() = runTest {
val viewModel = GeneratorViewModel(initialSavedStateHandle)
val action = GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect(
passcodeTypeOption = GeneratorState.MainType.Passcode.PasscodeTypeOption.PASSPHRASE,
)
viewModel.actionChannel.trySend(action)
val expectedState = initialState.copy(
selectedType = GeneratorState.MainType.Passcode(
selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase(),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Nested
inner class PassphraseActions {
private val defaultPassphraseState = createPassphraseState()
private val passphraseSavedStateHandle =
createSavedStateHandleWithState(defaultPassphraseState)
private lateinit var viewModel: GeneratorViewModel
@BeforeEach
fun setup() {
viewModel = GeneratorViewModel(passphraseSavedStateHandle)
}
@Test
fun `NumWordsCounterChange should update the numWords property correctly`() =
runTest {
viewModel.eventFlow.test {
val newNumWords = 4
viewModel.actionChannel.trySend(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange(
numWords = newNumWords,
),
)
val expectedState = defaultPassphraseState.copy(
generatedText = "redlohecalP",
selectedType = GeneratorState.MainType.Passcode(
GeneratorState.MainType.Passcode.PasscodeType.Passphrase(
numWords = newNumWords,
),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
@Test
fun `WordSeparatorTextChange should update wordSeparator correctly to new value`() =
runTest {
viewModel.eventFlow.test {
val newWordSeparatorChar = '_'
viewModel.actionChannel.trySend(
GeneratorAction
.MainType
.Passcode
.PasscodeType.Passphrase
.WordSeparatorTextChange(
wordSeparator = newWordSeparatorChar,
),
)
val expectedState = defaultPassphraseState.copy(
generatedText = "redlohecalP",
selectedType = GeneratorState.MainType.Passcode(
GeneratorState.MainType.Passcode.PasscodeType.Passphrase(
wordSeparator = newWordSeparatorChar,
),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
@Test
fun `ToggleIncludeNumberChange should update the includeNumber property correctly`() =
runTest {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange(
includeNumber = true,
),
)
val expectedState = defaultPassphraseState.copy(
generatedText = "redlohecalP",
selectedType = GeneratorState.MainType.Passcode(
selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase(
includeNumber = true,
),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
@Test
fun `ToggleCapitalizeChange should update the capitalize property correctly`() =
runTest {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange(
capitalize = true,
),
)
val expectedState = defaultPassphraseState.copy(
generatedText = "redlohecalP",
selectedType = GeneratorState.MainType.Passcode(
GeneratorState.MainType.Passcode.PasscodeType.Passphrase(
capitalize = true,
),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
}
//region Helper Functions
private fun createPasswordState(
generatedText: String = "Placeholder",
length: Int = 10,
): GeneratorState =
GeneratorState(
generatedText = generatedText,
selectedType = GeneratorState.MainType.Passcode(
GeneratorState.MainType.Passcode.PasscodeType.Password(length = length),
),
)
private fun createPassphraseState(
generatedText: String = "Placeholder",
numWords: Int = 3,
wordSeparator: Char = '-',
capitalize: Boolean = false,
includeNumber: Boolean = false,
): GeneratorState =
GeneratorState(
generatedText = generatedText,
selectedType = GeneratorState.MainType.Passcode(
GeneratorState.MainType.Passcode.PasscodeType.Passphrase(
numWords = numWords,
wordSeparator = wordSeparator,
capitalize = capitalize,
includeNumber = includeNumber,
),
),
)
private fun createSavedStateHandleWithState(state: GeneratorState) =
SavedStateHandle().apply {
set("state", state)
}
//endregion Helper Functions
}