From 23a053edb8ea73ea354bc3a7063fea2e8ab789f8 Mon Sep 17 00:00:00 2001 From: joshua-livefront <139182194+joshua-livefront@users.noreply.github.com> Date: Mon, 25 Sep 2023 15:12:26 -0400 Subject: [PATCH] BIT-333: Handle Passphrase state in GeneratorViewModel (#64) --- .../feature/generator/GeneratorScreen.kt | 553 +++++++++++++++--- .../feature/generator/GeneratorViewModel.kt | 487 +++++++++++++++ .../generator/GeneratorViewModelTest.kt | 239 ++++++++ 3 files changed, 1182 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 68e575eb48..88fc33f0bf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -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, + 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, + 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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt new file mode 100644 index 0000000000..32af6819a4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -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( + 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 + 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 +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt new file mode 100644 index 0000000000..c8859ccd0d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -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 +}