BIT-659: Adding UI for plus addressed email generator (#293)

This commit is contained in:
joshua-livefront 2023-11-29 12:28:55 -05:00 committed by Álison Fernandes
parent 8e099a546d
commit 90f565e02b
4 changed files with 507 additions and 20 deletions

View file

@ -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,
),
)
},
)
}
}
}

View file

@ -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()
}
}
}
}
/**

View file

@ -54,7 +54,7 @@ class GeneratorScreenTest : BaseComposeTest() {
extraBufferCapacity = Int.MAX_VALUE,
)
private val viewModel = mockk< GeneratorViewModel >(relaxed = true) {
private val viewModel = mockk<GeneratorViewModel>(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
}

View file

@ -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,