From ff8b9cd0b41ed18f49da3c37a6e7a7ebdf318826 Mon Sep 17 00:00:00 2001 From: joshua-livefront <139182194+joshua-livefront@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:21:16 -0500 Subject: [PATCH] BIT-662: Adding UI for random word generator (#313) --- .../feature/generator/GeneratorScreen.kt | 107 +++++++++++++++++- .../feature/generator/GeneratorViewModel.kt | 91 ++++++++++++++- .../feature/generator/GeneratorScreenTest.kt | 68 ++++++++++- .../generator/GeneratorViewModelTest.kt | 79 +++++++++++++ 4 files changed, 338 insertions(+), 7 deletions(-) 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 f93298ac1..7e10c6aec 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 @@ -150,6 +150,10 @@ fun GeneratorScreen( CatchAllEmailHandlers.create(viewModel = viewModel) } + val randomWordHandlers = remember(viewModel) { + RandomWordHandlers.create(viewModel = viewModel) + } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -179,6 +183,7 @@ fun GeneratorScreen( passphraseHandlers = passphraseHandlers, plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, + randomWordHandlers = randomWordHandlers, modifier = Modifier.padding(innerPadding), ) } @@ -199,6 +204,7 @@ private fun ScrollContent( passphraseHandlers: PassphraseHandlers, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, + randomWordHandlers: RandomWordHandlers, modifier: Modifier = Modifier, ) { Column( @@ -248,6 +254,7 @@ private fun ScrollContent( onSubStateOptionClicked = onUsernameSubStateOptionClicked, plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, + randomWordHandlers = randomWordHandlers, ) } } @@ -703,6 +710,7 @@ private fun ColumnScope.UsernameTypeItems( onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, + randomWordHandlers: RandomWordHandlers, ) { UsernameOptionsItem(usernameState, onSubStateOptionClicked) @@ -726,7 +734,10 @@ private fun ColumnScope.UsernameTypeItems( } is GeneratorState.MainType.Username.UsernameType.RandomWord -> { - // TODO: Implement RandomWord BIT-658 + RandomWordTypeContent( + randomWordTypeState = selectedType, + randomWordHandlers = randomWordHandlers, + ) } } } @@ -833,6 +844,58 @@ private fun CatchAllEmailTextInputItem( //endregion CatchAllEmailType Composables +//region Random Word Composables + +@Composable +private fun ColumnScope.RandomWordTypeContent( + randomWordTypeState: GeneratorState.MainType.Username.UsernameType.RandomWord, + randomWordHandlers: RandomWordHandlers, +) { + Spacer(modifier = Modifier.height(16.dp)) + + RandomWordCapitalizeToggleItem( + capitalize = randomWordTypeState.capitalize, + onRandomWordCapitalizeToggleChange = randomWordHandlers.onCapitalizeChange, + ) + + RandomWordIncludeNumberToggleItem( + includeNumber = randomWordTypeState.includeNumber, + onRandomWordIncludeNumberToggleChange = randomWordHandlers.onIncludeNumberChange, + ) +} + +@Composable +private fun RandomWordCapitalizeToggleItem( + capitalize: Boolean, + onRandomWordCapitalizeToggleChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.capitalize), + isChecked = capitalize, + onCheckedChange = onRandomWordCapitalizeToggleChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +} + +@Composable +private fun RandomWordIncludeNumberToggleItem( + includeNumber: Boolean, + onRandomWordIncludeNumberToggleChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.include_number), + isChecked = includeNumber, + onCheckedChange = onRandomWordIncludeNumberToggleChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +} + +//endregion Random Word Composables + @Preview(showBackground = true) @Composable private fun GeneratorPreview() { @@ -1038,3 +1101,45 @@ private class CatchAllEmailHandlers( } } } + +/** + * A class dedicated to handling user interactions related to Random Word + * configuration. + * Each lambda corresponds to a specific user action, allowing for easy delegation of + * logic when user input is detected. + */ +private class RandomWordHandlers( + val onCapitalizeChange: (Boolean) -> Unit, + val onIncludeNumberChange: (Boolean) -> Unit, +) { + companion object { + fun create(viewModel: GeneratorViewModel): RandomWordHandlers { + return RandomWordHandlers( + onCapitalizeChange = { shouldCapitalize -> + viewModel.trySendAction( + GeneratorAction + .MainType + .Username + .UsernameType + .RandomWord + .ToggleCapitalizeChange( + capitalize = shouldCapitalize, + ), + ) + }, + onIncludeNumberChange = { shouldIncludeNumber -> + viewModel.trySendAction( + GeneratorAction + .MainType + .Username + .UsernameType + .RandomWord + .ToggleIncludeNumberChange( + includeNumber = shouldIncludeNumber, + ), + ) + }, + ) + } + } +} 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 index dc1a59e89..dc40d7c84 100644 --- 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 @@ -20,9 +20,10 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa 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 com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.CatchAllEmail import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.AnonAddy import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail -import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.CatchAllEmail +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.RandomWord import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn @@ -109,6 +110,10 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.MainType.Username.UsernameType.CatchAllEmail.DomainTextChange -> { handleCatchAllEmailTextInputChange(action) } + + is GeneratorAction.MainType.Username.UsernameType.RandomWord -> { + handleRandomWordSpecificAction(action) + } } } @@ -451,12 +456,12 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange, -> { - handleToggleCapitalizeChange(action) + handlePassphraseToggleCapitalizeChange(action) } is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange, -> { - handleToggleIncludeNumberChange(action) + handlePassphraseToggleIncludeNumberChange(action) } is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange, @@ -466,7 +471,7 @@ class GeneratorViewModel @Inject constructor( } } - private fun handleToggleCapitalizeChange( + private fun handlePassphraseToggleCapitalizeChange( action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange, ) { updatePassphraseType { currentPassphraseType -> @@ -476,7 +481,7 @@ class GeneratorViewModel @Inject constructor( } } - private fun handleToggleIncludeNumberChange( + private fun handlePassphraseToggleIncludeNumberChange( action: GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange, ) { updatePassphraseType { currentPassphraseType -> @@ -558,6 +563,50 @@ class GeneratorViewModel @Inject constructor( //endregion Catch-All Email Specific Handlers + //region Random Word Specific Handlers + + private fun handleRandomWordSpecificAction( + action: GeneratorAction.MainType.Username.UsernameType.RandomWord, + ) { + when (action) { + is GeneratorAction.MainType.Username.UsernameType.RandomWord.ToggleCapitalizeChange -> { + handleRandomWordToggleCapitalizeChange(action) + } + + is GeneratorAction + .MainType + .Username + .UsernameType + .RandomWord + .ToggleIncludeNumberChange, + -> { + handleRandomWordToggleIncludeNumberChange(action) + } + } + } + + private fun handleRandomWordToggleCapitalizeChange( + action: GeneratorAction.MainType.Username.UsernameType.RandomWord.ToggleCapitalizeChange, + ) { + updateRandomWordType { currentRandomWordType -> + currentRandomWordType.copy( + capitalize = action.capitalize, + ) + } + } + + private fun handleRandomWordToggleIncludeNumberChange( + action: GeneratorAction.MainType.Username.UsernameType.RandomWord.ToggleIncludeNumberChange, + ) { + updateRandomWordType { currentRandomWordType -> + currentRandomWordType.copy( + includeNumber = action.includeNumber, + ) + } + } + + //endregion Random Word Specific Handlers + //region Utility Functions private inline fun updateGeneratorMainType( @@ -653,6 +702,18 @@ class GeneratorViewModel @Inject constructor( } } + private inline fun updateRandomWordType( + crossinline block: (RandomWord) -> RandomWord, + ) { + updateGeneratorMainTypeUsername { currentSelectedType -> + val currentUsernameType = currentSelectedType.selectedType + if (currentUsernameType !is RandomWord) { + return@updateGeneratorMainTypeUsername currentSelectedType + } + currentSelectedType.copy(selectedType = block(currentUsernameType)) + } + } + //endregion Utility Functions companion object { @@ -1262,6 +1323,26 @@ sealed class GeneratorAction { */ data class DomainTextChange(val domain: String) : CatchAllEmail() } + + /** + * Represents actions specifically related to Random Word. + */ + sealed class RandomWord : UsernameType() { + + /** + * Fired when the "capitalize" toggle is changed. + * + * @property capitalize The new value of the "capitalize" toggle. + */ + data class ToggleCapitalizeChange(val capitalize: Boolean) : RandomWord() + + /** + * Fired when the "include number" toggle is changed. + * + * @property includeNumber The new value of the "include number" toggle. + */ + data class ToggleIncludeNumberChange(val includeNumber: Boolean) : RandomWord() + } } } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 753d6fc71..afc4f68c8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -993,6 +993,10 @@ class GeneratorScreenTest : BaseComposeTest() { } } + //endregion Username Plus Addressed Email Tests + + //region Catch-All Email Tests + @Suppress("MaxLineLength") @Test fun `in Username_CatchAllEmail state, updating text in email field should send EmailTextChange action`() { @@ -1028,7 +1032,69 @@ class GeneratorScreenTest : BaseComposeTest() { } } - //endregion Username Plus Addressed Email Tests + //endregion Catch-All Email Tests + + //region Random Word Tests + + @Suppress("MaxLineLength") + @Test + fun `in Username_RandomWord state, toggling capitalize should send ToggleCapitalizeChange action`() { + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.RandomWord(), + ), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Capitalize") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.RandomWord.ToggleCapitalizeChange( + capitalize = true, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in Username_RandomWord state, toggling the include number toggle should send ToggleIncludeNumberChange action`() { + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.RandomWord(), + ), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Include number") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.RandomWord.ToggleIncludeNumberChange( + includeNumber = true, + ), + ) + } + } + + //endregion Random Word Tests private fun updateState(state: GeneratorState) { mutableStateFlow.value = state 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 index 20dcd6ca9..f3c1db0ec 100644 --- 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 @@ -31,6 +31,9 @@ class GeneratorViewModelTest : BaseViewModelTest() { private val catchAllEmailSavedStateHandle = createSavedStateHandleWithState(initialCatchAllEmailState) + private val initialRandomWordState = createRandomWordState() + private val randomWordSavedStateHandle = createSavedStateHandleWithState(initialRandomWordState) + private val fakeGeneratorRepository = FakeGeneratorRepository().apply { setMockGeneratePasswordResult( GeneratedPasswordResult.Success("defaultPassword"), @@ -872,6 +875,67 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } } + + @Nested + inner class RandomWordActions { + private val defaultRandomWordState = createRandomWordState() + private lateinit var viewModel: GeneratorViewModel + + @BeforeEach + fun setup() { + viewModel = GeneratorViewModel(randomWordSavedStateHandle, fakeGeneratorRepository) + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleCapitalizeChange should update the capitalize property correctly`() = runTest { + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Username + .UsernameType + .RandomWord + .ToggleCapitalizeChange( + capitalize = true, + ), + ) + + val expectedState = defaultRandomWordState.copy( + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.RandomWord( + capitalize = true, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleIncludeNumberChange should update the includeNumber property correctly`() = runTest { + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Username + .UsernameType + .RandomWord + .ToggleIncludeNumberChange( + includeNumber = true, + ), + ) + + val expectedState = defaultRandomWordState.copy( + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.RandomWord( + includeNumber = true, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } //region Helper Functions @Suppress("LongParameterList") @@ -947,6 +1011,21 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), ) + private fun createRandomWordState( + generatedText: String = "defaultRandomWord", + capitalize: Boolean = false, + includeNumber: Boolean = false, + ): GeneratorState = + GeneratorState( + generatedText = generatedText, + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.RandomWord( + capitalize = capitalize, + includeNumber = includeNumber, + ), + ), + ) + private fun createSavedStateHandleWithState(state: GeneratorState) = SavedStateHandle().apply { set("state", state)