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 0729d8f8bc..05ef37419e 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarDuration @@ -121,10 +122,23 @@ fun GeneratorScreen( } } + val onUsernameOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit = + remember(viewModel) { + { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + it, + ), + ) + } + } + val passwordHandlers = PasswordHandlers.create(viewModel = viewModel) val passphraseHandlers = PassphraseHandlers.create(viewModel = viewModel) + val plusAddressedEmailHandlers = PlusAddressedEmailHandlers.create(viewModel = viewModel) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -148,9 +162,11 @@ fun GeneratorScreen( onRegenerateClick = onRegenerateClick, onCopyClick = onCopyClick, onMainStateOptionClicked = onMainStateOptionClicked, - onSubStateOptionClicked = onPasscodeOptionClicked, + onPasscodeSubStateOptionClicked = onPasscodeOptionClicked, + onUsernameSubStateOptionClicked = onUsernameOptionClicked, passwordHandlers = passwordHandlers, passphraseHandlers = passphraseHandlers, + plusAddressedEmailHandlers = plusAddressedEmailHandlers, modifier = Modifier.padding(innerPadding), ) } @@ -165,9 +181,11 @@ private fun ScrollContent( onRegenerateClick: () -> Unit, onCopyClick: () -> Unit, onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit, - onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit, + onPasscodeSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit, + onUsernameSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, passwordHandlers: PasswordHandlers, passphraseHandlers: PassphraseHandlers, + plusAddressedEmailHandlers: PlusAddressedEmailHandlers, modifier: Modifier = Modifier, ) { Column( @@ -205,14 +223,18 @@ private fun ScrollContent( is GeneratorState.MainType.Passcode -> { PasscodeTypeItems( passcodeState = selectedType, - onSubStateOptionClicked = onSubStateOptionClicked, + onSubStateOptionClicked = onPasscodeSubStateOptionClicked, passwordHandlers = passwordHandlers, passphraseHandlers = passphraseHandlers, ) } is GeneratorState.MainType.Username -> { - // TODO(BIT-335): Username state to handle Plus Addressed Email + UsernameTypeItems( + usernameState = selectedType, + onSubStateOptionClicked = onUsernameSubStateOptionClicked, + plusAddressedEmailHandlers = plusAddressedEmailHandlers, + ) } } } @@ -661,6 +683,109 @@ private fun PassphraseIncludeNumberToggleItem( //endregion PassphraseType Composables +//region UsernameType Composables + +@Composable +private fun UsernameTypeItems( + usernameState: GeneratorState.MainType.Username, + onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, + plusAddressedEmailHandlers: PlusAddressedEmailHandlers, +) { + UsernameOptionsItem(usernameState, onSubStateOptionClicked) + + when (val selectedType = usernameState.selectedType) { + is GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail -> { + PlusAddressedEmailTypeContent( + usernameTypeState = selectedType, + plusAddressedEmailHandlers = plusAddressedEmailHandlers, + ) + } + + is GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias -> { + // TODO: Implement ForwardedEmailAlias BIT-657 + } + + is GeneratorState.MainType.Username.UsernameType.CatchAllEmail -> { + // TODO: Implement CatchAllEmail BIT-656 + } + + is GeneratorState.MainType.Username.UsernameType.RandomWord -> { + // TODO: Implement RandomWord BIT-658 + } + } +} + +@Composable +private fun UsernameOptionsItem( + currentSubState: GeneratorState.MainType.Username, + onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, +) { + val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.values().toList() + val optionsWithStrings = + possibleSubStates.associateBy({ it }, { stringResource(id = it.labelRes) }) + + BitwardenMultiSelectButton( + label = stringResource(id = R.string.username_type), + options = optionsWithStrings.values.toList(), + selectedOption = stringResource(id = currentSubState.selectedType.displayStringResId), + onOptionSelected = { selectedOption -> + val selectedOptionId = + optionsWithStrings.entries.first { it.value == selectedOption }.key + onSubStateOptionClicked(selectedOptionId) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) +} + +//endregion UsernameType Composables + +//region PlusAddressedEmailType Composables + +@Composable +private fun PlusAddressedEmailTypeContent( + usernameTypeState: GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail, + plusAddressedEmailHandlers: PlusAddressedEmailHandlers, +) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(id = R.string.plus_addressed_email_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + PlusAddressedEmailTextInputItem( + email = usernameTypeState.email, + onPlusAddressedEmailTextChange = plusAddressedEmailHandlers.onEmailChange, + ) +} + +@Composable +private fun PlusAddressedEmailTextInputItem( + email: String, + onPlusAddressedEmailTextChange: (email: String) -> Unit, +) { + BitwardenTextField( + label = stringResource(id = R.string.email_required_parenthesis), + value = email, + onValueChange = { + onPlusAddressedEmailTextChange(it) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +} + +//endregion PlusAddressedEmailType Composables + @Preview(showBackground = true) @Composable private fun GeneratorPreview() { @@ -808,3 +933,32 @@ private class PassphraseHandlers( } } } + +/** + * A class dedicated to handling user interactions related to plus addressed email + * configuration. + * Each lambda corresponds to a specific user action, allowing for easy delegation of + * logic when user input is detected. + */ +private class PlusAddressedEmailHandlers( + val onEmailChange: (String) -> Unit, +) { + companion object { + fun create(viewModel: GeneratorViewModel): PlusAddressedEmailHandlers { + return PlusAddressedEmailHandlers( + onEmailChange = { newEmail -> + viewModel.trySendAction( + GeneratorAction + .MainType + .Username + .UsernameType + .PlusAddressedEmail + .EmailTextChange( + email = newEmail, + ), + ) + }, + ) + } + } +} 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 f04a849be4..6354e6207a 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 @@ -95,6 +95,15 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.Internal.UpdateGeneratedPassphraseResult -> { handleUpdateGeneratedPassphraseResult(action) } + + is GeneratorAction.MainType.Username.UsernameTypeOptionSelect -> { + handleUsernameTypeOptionSelect(action) + } + + is GeneratorAction.MainType.Username.UsernameType.PlusAddressedEmail.EmailTextChange -> + { + handlePlusAddressedEmailTextInputChange(action) + } } } @@ -492,6 +501,45 @@ class GeneratorViewModel @Inject constructor( //endregion Passphrase Specific Handlers + //region Username Type Handlers + + private fun handleUsernameTypeOptionSelect( + action: GeneratorAction.MainType.Username.UsernameTypeOptionSelect, + ) { + when (action.usernameTypeOption) { + Username.UsernameTypeOption.PLUS_ADDRESSED_EMAIL -> loadUsernameOptions( + selectedType = Username(selectedType = PlusAddressedEmail()), + ) + + Username.UsernameTypeOption.CATCH_ALL_EMAIL -> loadUsernameOptions( + selectedType = Username(selectedType = Username.UsernameType.CatchAllEmail()), + ) + + Username.UsernameTypeOption.FORWARDED_EMAIL_ALIAS -> loadUsernameOptions( + selectedType = Username(selectedType = Username.UsernameType.ForwardedEmailAlias()), + ) + + Username.UsernameTypeOption.RANDOM_WORD -> loadUsernameOptions( + selectedType = Username(selectedType = Username.UsernameType.RandomWord()), + ) + } + } + + //endregion Username Type Handlers + + //region Plus Addressed Email Specific Handlers + + private fun handlePlusAddressedEmailTextInputChange( + action: GeneratorAction.MainType.Username.UsernameType.PlusAddressedEmail.EmailTextChange, + ) { + updatePlusAddressedEmailType { plusAddressedEmailType -> + val newEmail = action.email + plusAddressedEmailType.copy(email = newEmail) + } + } + + //endregion Plus Addressed Email Specific Handlers + //region Utility Functions private inline fun updateGeneratorMainType( @@ -555,6 +603,26 @@ class GeneratorViewModel @Inject constructor( } } + private inline fun updateGeneratorMainTypeUsername( + crossinline block: (Username) -> Username, + ) { + updateGeneratorMainType { + if (it !is Username) null else block(it) + } + } + + private inline fun updatePlusAddressedEmailType( + crossinline block: (PlusAddressedEmail) -> PlusAddressedEmail, + ) { + updateGeneratorMainTypeUsername { currentSelectedType -> + val currentUsernameType = currentSelectedType.selectedType + if (currentUsernameType !is PlusAddressedEmail) { + return@updateGeneratorMainTypeUsername currentSelectedType + } + currentSelectedType.copy(selectedType = block(currentUsernameType)) + } + } + //endregion Utility Functions companion object { @@ -1107,7 +1175,36 @@ sealed class GeneratorAction { * This sealed class serves as a placeholder for future extensions * related to the username actions in the generator. */ - sealed class Username : MainType() + sealed class Username : MainType() { + + /** + * Represents the action of selecting a username type option. + * + * @property usernameTypeOption The selected username type option. + */ + data class UsernameTypeOptionSelect( + val usernameTypeOption: GeneratorState.MainType.Username.UsernameTypeOption, + ) : Username() + + /** + * Represents actions related to the different types of usernames. + */ + sealed class UsernameType : Username() { + + /** + * Represents actions specifically related to Plus Addressed Email. + */ + sealed class PlusAddressedEmail : UsernameType() { + + /** + * Fired when the email text input is changed. + * + * @property email The new email text. + */ + data class EmailTextChange(val email: String) : PlusAddressedEmail() + } + } + } } /** 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 2368b354a7..b25062ae63 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 @@ -54,7 +54,7 @@ class GeneratorScreenTest : BaseComposeTest() { extraBufferCapacity = Int.MAX_VALUE, ) - private val viewModel = mockk< GeneratorViewModel >(relaxed = true) { + private val viewModel = mockk(relaxed = true) { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } @@ -165,6 +165,49 @@ class GeneratorScreenTest : BaseComposeTest() { .assertDoesNotExist() } + @Test + fun `clicking a UsernameOption should send UsernameTypeOption action`() { + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail( + email = "email", + ), + ), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + // Opens the menu + composeTestRule + .onNodeWithContentDescription(label = "Username type, Plus addressed email") + .performClick() + + // Choose the option from the menu + composeTestRule + .onAllNodesWithText(text = "Random word") + .onLast() + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + GeneratorState.MainType.Username.UsernameTypeOption.RANDOM_WORD, + ), + ) + } + + // Make sure dialog is hidden: + composeTestRule + .onNode(isDialog()) + .assertDoesNotExist() + } + //region Passcode Password Tests @Test @@ -913,6 +956,45 @@ class GeneratorScreenTest : BaseComposeTest() { //endregion Passcode Passphrase Tests + //region Username Plus Addressed Email Tests + + @Suppress("MaxLineLength") + @Test + fun `in Username_PlusAddressedEmail state, updating text in email field should send EmailTextChange action`() { + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail( + email = "", + ), + ), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + val newEmail = "test@example.com" + + // Find the text field for PlusAddressedEmail and input text + composeTestRule + .onNodeWithText("Email (required)") + .performScrollTo() + .performTextInput(newEmail) + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.PlusAddressedEmail.EmailTextChange( + email = newEmail, + ), + ) + } + } + + //endregion Username Plus Addressed Email 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 5056e66ca9..853b076a3f 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 @@ -17,13 +17,14 @@ import org.junit.jupiter.api.Test class GeneratorViewModelTest : BaseViewModelTest() { - private val initialState = createPasswordState() - private val initialSavedStateHandle = createSavedStateHandleWithState(initialState) + private val initialPasscodeState = createPasswordState() + private val initialPasscodeSavedStateHandle = + createSavedStateHandleWithState(initialPasscodeState) private val initialPassphraseState = createPassphraseState() private val passphraseSavedStateHandle = createSavedStateHandleWithState(initialPassphraseState) - private val initialUsernameState = createUsernameState() + private val initialUsernameState = createPlusAddressedEmailState() private val usernameSavedStateHandle = createSavedStateHandleWithState(initialUsernameState) private val fakeGeneratorRepository = FakeGeneratorRepository().apply { @@ -36,7 +37,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { fun `initial state should be correct`() = runTest { val viewModel = createViewModel() viewModel.stateFlow.test { - assertEquals(initialState, awaitItem()) + assertEquals(initialPasscodeState, awaitItem()) } } @@ -198,7 +199,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(action) val expectedState = - initialState.copy( + initialPasscodeState.copy( selectedType = GeneratorState.MainType.Passcode(), generatedText = "updatedText", ) @@ -218,7 +219,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(action) val expectedState = - initialState.copy(selectedType = GeneratorState.MainType.Username()) + initialPasscodeState.copy(selectedType = GeneratorState.MainType.Username()) assertEquals(expectedState, viewModel.stateFlow.value) } @@ -236,7 +237,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(action) - val expectedState = initialState.copy( + val expectedState = initialPasscodeState.copy( selectedType = GeneratorState.MainType.Passcode( selectedType = GeneratorState.MainType.Passcode.PasscodeType.Password(), ), @@ -261,7 +262,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(action) - val expectedState = initialState.copy( + val expectedState = initialPasscodeState.copy( generatedText = updatedText, selectedType = GeneratorState.MainType.Passcode( selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase(), @@ -271,6 +272,109 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Test + fun `UsernameTypeOptionSelect PLUS_ADDRESSED_EMAIL should switch to PlusAddressedEmail type`() = + runTest { + val viewModel = createViewModel(initialUsernameState) + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + usernameTypeOption = GeneratorState + .MainType + .Username + .UsernameTypeOption + .PLUS_ADDRESSED_EMAIL, + ), + ) + + val expectedState = initialUsernameState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState + .MainType + .Username + .UsernameType + .PlusAddressedEmail(), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `UsernameTypeOptionSelect CATCH_ALL_EMAIL should switch to CatchAllEmail type`() = runTest { + val viewModel = createViewModel(initialUsernameState) + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + usernameTypeOption = GeneratorState + .MainType + .Username + .UsernameTypeOption + .CATCH_ALL_EMAIL, + ), + ) + + val expectedState = initialUsernameState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState.MainType.Username.UsernameType.CatchAllEmail(), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `UsernameTypeOptionSelect FORWARDED_EMAIL_ALIAS should switch to ForwardedEmailAlias type`() = + runTest { + val viewModel = createViewModel(initialUsernameState) + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + usernameTypeOption = GeneratorState + .MainType + .Username + .UsernameTypeOption + .FORWARDED_EMAIL_ALIAS, + ), + ) + + val expectedState = initialUsernameState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias(), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `UsernameTypeOptionSelect RANDOM_WORD should switch to RandomWord type`() = runTest { + val viewModel = createViewModel(initialUsernameState) + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Username.UsernameTypeOptionSelect( + usernameTypeOption = GeneratorState + .MainType + .Username + .UsernameTypeOption + .RANDOM_WORD, + ), + ) + + val expectedState = initialUsernameState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState.MainType.Username.UsernameType.RandomWord(), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + @Nested inner class PasswordActions { private val defaultPasswordState = createPasswordState() @@ -281,7 +385,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { fakeGeneratorRepository.setMockGeneratePasswordResult( GeneratedPasswordResult.Success("defaultPassword"), ) - viewModel = GeneratorViewModel(initialSavedStateHandle, fakeGeneratorRepository) + viewModel = GeneratorViewModel(initialPasscodeSavedStateHandle, fakeGeneratorRepository) } @Suppress("MaxLineLength") @@ -680,6 +784,48 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } } + + @Nested + inner class PlusAddressedEmailActions { + private val defaultPlusAddressedEmailState = createPlusAddressedEmailState() + private lateinit var viewModel: GeneratorViewModel + + @BeforeEach + fun setup() { + viewModel = GeneratorViewModel(usernameSavedStateHandle, fakeGeneratorRepository) + } + + @Suppress("MaxLineLength") + @Test + fun `EmailTextChange should update email correctly`() = + runTest { + val newEmail = "test@example.com" + viewModel.actionChannel.trySend( + GeneratorAction + .MainType + .Username + .UsernameType + .PlusAddressedEmail + .EmailTextChange( + email = newEmail, + ), + ) + + val expectedState = defaultPlusAddressedEmailState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState + .MainType + .Username + .UsernameType + .PlusAddressedEmail( + email = newEmail, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } //region Helper Functions @Suppress("LongParameterList") @@ -729,10 +875,18 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), ) - private fun createUsernameState(): GeneratorState = GeneratorState( - generatedText = "defaultUsername", - selectedType = GeneratorState.MainType.Username(), - ) + private fun createPlusAddressedEmailState( + generatedText: String = "defaultPlusAddressedEmail", + email: String = "defaultEmail", + ): GeneratorState = + GeneratorState( + generatedText = generatedText, + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail( + email = email, + ), + ), + ) private fun createSavedStateHandleWithState(state: GeneratorState) = SavedStateHandle().apply { @@ -740,7 +894,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { } private fun createViewModel( - state: GeneratorState? = initialState, + state: GeneratorState? = initialPasscodeState, ): GeneratorViewModel = GeneratorViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, generatorRepository = fakeGeneratorRepository,