mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 11:59:57 +03:00
BIT-333: Handle Passphrase state in GeneratorViewModel (#64)
This commit is contained in:
parent
5a05988d16
commit
23a053edb8
3 changed files with 1182 additions and 97 deletions
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Add table
Reference in a new issue