BIT-871: Adding UI for forwarded email alias (#440)

This commit is contained in:
joshua-livefront 2023-12-28 11:46:34 -05:00 committed by Álison Fernandes
parent 2a9cec5f8e
commit 492038f163
5 changed files with 326 additions and 9 deletions

View file

@ -44,7 +44,8 @@ import kotlinx.collections.immutable.persistentListOf
*
* @param label The descriptive text label for the [OutlinedTextField].
* @param options A list of strings representing the available options in the dialog.
* @param selectedOption The currently selected option that is displayed in the [OutlinedTextField].
* @param selectedOption The currently selected option that is displayed in the [OutlinedTextField]
* (or `null` if no option is selected).
* @param onOptionSelected A lambda that is invoked when an option
* is selected from the dropdown menu.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
@ -56,7 +57,7 @@ import kotlinx.collections.immutable.persistentListOf
fun BitwardenMultiSelectButton(
label: String,
options: ImmutableList<String>,
selectedOption: String,
selectedOption: String?,
onOptionSelected: (String) -> Unit,
modifier: Modifier = Modifier,
supportingText: String? = null,
@ -104,7 +105,7 @@ fun BitwardenMultiSelectButton(
}
}
},
value = selectedOption,
value = selectedOption ?: "",
onValueChange = onOptionSelected,
enabled = shouldShowDialog,
trailingIcon = {

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.toDp
@ -56,6 +55,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
@ -67,6 +67,8 @@ import com.x8bit.bitwarden.ui.platform.components.model.TooltipData
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOption
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.Companion.PASSWORD_COUNTER_MAX
@ -151,6 +153,10 @@ fun GeneratorScreen(
PassphraseHandlers.create(viewModel = viewModel)
}
val forwardedEmailAliasHandlers = remember(viewModel) {
ForwardedEmailAliasHandlers.create(viewModel = viewModel)
}
val plusAddressedEmailHandlers = remember(viewModel) {
PlusAddressedEmailHandlers.create(viewModel = viewModel)
}
@ -203,6 +209,7 @@ fun GeneratorScreen(
onUsernameSubStateOptionClicked = onUsernameOptionClicked,
passwordHandlers = passwordHandlers,
passphraseHandlers = passphraseHandlers,
forwardedEmailAliasHandlers = forwardedEmailAliasHandlers,
plusAddressedEmailHandlers = plusAddressedEmailHandlers,
catchAllEmailHandlers = catchAllEmailHandlers,
randomWordHandlers = randomWordHandlers,
@ -224,6 +231,7 @@ private fun ScrollContent(
onUsernameSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit,
passwordHandlers: PasswordHandlers,
passphraseHandlers: PassphraseHandlers,
forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers,
plusAddressedEmailHandlers: PlusAddressedEmailHandlers,
catchAllEmailHandlers: CatchAllEmailHandlers,
randomWordHandlers: RandomWordHandlers,
@ -274,6 +282,7 @@ private fun ScrollContent(
UsernameTypeItems(
usernameState = selectedType,
onSubStateOptionClicked = onUsernameSubStateOptionClicked,
forwardedEmailAliasHandlers = forwardedEmailAliasHandlers,
plusAddressedEmailHandlers = plusAddressedEmailHandlers,
catchAllEmailHandlers = catchAllEmailHandlers,
randomWordHandlers = randomWordHandlers,
@ -734,6 +743,7 @@ private fun PassphraseIncludeNumberToggleItem(
private fun ColumnScope.UsernameTypeItems(
usernameState: GeneratorState.MainType.Username,
onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit,
forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers,
plusAddressedEmailHandlers: PlusAddressedEmailHandlers,
catchAllEmailHandlers: CatchAllEmailHandlers,
randomWordHandlers: RandomWordHandlers,
@ -749,7 +759,10 @@ private fun ColumnScope.UsernameTypeItems(
}
is GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias -> {
// TODO: Implement ForwardedEmailAlias BIT-657
ForwardedEmailAliasTypeContent(
usernameTypeState = selectedType,
forwardedEmailAliasHandlers = forwardedEmailAliasHandlers,
)
}
is GeneratorState.MainType.Username.UsernameType.CatchAllEmail -> {
@ -773,7 +786,7 @@ private fun UsernameOptionsItem(
currentSubState: GeneratorState.MainType.Username,
onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit,
) {
val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.values().toList()
val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.entries
val optionsWithStrings = possibleSubStates.associateWith { stringResource(id = it.labelRes) }
BitwardenMultiSelectButton(
@ -802,6 +815,83 @@ private fun UsernameOptionsItem(
//endregion UsernameType Composables
//region ForwardedEmailAliasType Composables
@Composable
private fun ColumnScope.ForwardedEmailAliasTypeContent(
usernameTypeState: GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias,
forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers,
) {
Spacer(modifier = Modifier.height(8.dp))
ServiceTypeOptionsItem(
currentSubState = usernameTypeState,
onSubStateOptionClicked = forwardedEmailAliasHandlers.onServiceChange,
)
Spacer(modifier = Modifier.height(8.dp))
when (usernameTypeState.selectedServiceType) {
is ServiceType.AnonAddy -> {
// TODO: AnonAddy Service Implementation (BIT-711)
}
is ServiceType.DuckDuckGo -> {
// TODO: DuckDuckGo Service Implementation (BIT-714)
}
is ServiceType.FastMail -> {
// TODO: FastMail Service Implementation (BIT-712)
}
is ServiceType.FirefoxRelay -> {
// TODO: FirefoxRelay Service Implementation (BIT-1196)
}
is ServiceType.SimpleLogin -> {
// TODO: SimpleLogin Service Implementation (BIT-713)
}
null -> {
var obfuscatedTextField by remember { mutableStateOf("") }
BitwardenPasswordField(
label = "",
value = obfuscatedTextField,
onValueChange = { obfuscatedTextField = it },
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
}
}
@Composable
private fun ServiceTypeOptionsItem(
currentSubState: GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias,
onSubStateOptionClicked: (ServiceTypeOption) -> Unit,
) {
val possibleSubStates = ServiceTypeOption.entries
val optionsWithStrings = possibleSubStates.associateWith { stringResource(id = it.labelRes) }
BitwardenMultiSelectButton(
label = stringResource(id = R.string.service),
options = optionsWithStrings.values.toImmutableList(),
selectedOption = (currentSubState.selectedServiceType?.displayStringResId)?.let {
stringResource(id = it)
},
onOptionSelected = { selectedOption ->
val selectedOptionId =
optionsWithStrings.entries.first { it.value == selectedOption }.key
onSubStateOptionClicked(selectedOptionId)
},
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
//region PlusAddressedEmailType Composables
@Composable
@ -1073,6 +1163,35 @@ private class PassphraseHandlers(
}
}
/**
* A class dedicated to handling user interactions related to forwarded email alias
* configuration.
* Each lambda corresponds to a specific user action, allowing for easy delegation of
* logic when user input is detected.
*/
private class ForwardedEmailAliasHandlers(
val onServiceChange: (ServiceTypeOption) -> Unit,
) {
companion object {
fun create(viewModel: GeneratorViewModel): ForwardedEmailAliasHandlers {
return ForwardedEmailAliasHandlers(
onServiceChange = { newServiceTypeOption ->
viewModel.trySendAction(
GeneratorAction
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOptionSelect(
serviceTypeOption = newServiceTypeOption,
),
)
},
)
}
}
}
/**
* A class dedicated to handling user interactions related to plus addressed email
* configuration.

View file

@ -21,7 +21,8 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa
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.ForwardedEmailAlias
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType
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.RandomWord
import dagger.hilt.android.lifecycle.HiltViewModel
@ -44,6 +45,7 @@ private const val KEY_STATE = "state"
*
* @property savedStateHandle Handles the saved state of this ViewModel.
*/
@Suppress("LargeClass")
@HiltViewModel
class GeneratorViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
@ -107,6 +109,10 @@ class GeneratorViewModel @Inject constructor(
handleUsernameTypeOptionSelect(action)
}
is GeneratorAction.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOptionSelect -> {
handleServiceTypeOptionSelect(action)
}
is GeneratorAction.MainType.Username.UsernameType.PlusAddressedEmail.EmailTextChange -> {
handlePlusAddressedEmailTextInputChange(action)
}
@ -553,6 +559,41 @@ class GeneratorViewModel @Inject constructor(
//endregion Username Type Handlers
//region Forwarded Email Alias Specific Handlers
private fun handleServiceTypeOptionSelect(
action: GeneratorAction
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOptionSelect,
) {
when (action.serviceTypeOption) {
ForwardedEmailAlias.ServiceTypeOption.ANON_ADDY -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = ServiceType.AnonAddy())
}
ForwardedEmailAlias.ServiceTypeOption.DUCK_DUCK_GO -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = ServiceType.DuckDuckGo())
}
ForwardedEmailAlias.ServiceTypeOption.FAST_MAIL -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = ServiceType.FastMail())
}
ForwardedEmailAlias.ServiceTypeOption.FIREFOX_RELAY -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = ServiceType.FirefoxRelay())
}
ForwardedEmailAlias.ServiceTypeOption.SIMPLE_LOGIN -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = ServiceType.SimpleLogin())
}
}
}
//endregion Forwarded Email Alias Specific Handlers
//region Plus Addressed Email Specific Handlers
private fun handlePlusAddressedEmailTextInputChange(
@ -699,6 +740,18 @@ class GeneratorViewModel @Inject constructor(
}
}
private inline fun updateForwardedEmailAliasType(
crossinline block: (ForwardedEmailAlias) -> ForwardedEmailAlias,
) {
updateGeneratorMainTypeUsername { currentSelectedType ->
val currentUsernameType = currentSelectedType.selectedType
if (currentUsernameType !is ForwardedEmailAlias) {
return@updateGeneratorMainTypeUsername currentSelectedType
}
currentSelectedType.copy(selectedType = block(currentUsernameType))
}
}
private inline fun updatePlusAddressedEmailType(
crossinline block: (PlusAddressedEmail) -> PlusAddressedEmail,
) {
@ -1007,7 +1060,8 @@ data class GeneratorState(
*/
@Parcelize
data class ForwardedEmailAlias(
val selectedServiceType: ServiceType = AnonAddy(),
val selectedServiceType: ServiceType? = null,
val obfuscatedText: String = "",
) : UsernameType(), Parcelable {
override val displayStringResId: Int
get() = UsernameTypeOption.FORWARDED_EMAIL_ALIAS.labelRes
@ -1042,7 +1096,7 @@ data class GeneratorState(
* this property to provide the appropriate string resource ID for
* its display string.
*/
abstract val displayStringResId: Int
abstract val displayStringResId: Int?
/**
* Represents the Anon Addy service type, with a configurable option for
@ -1329,6 +1383,26 @@ sealed class GeneratorAction {
*/
sealed class UsernameType : Username() {
/**
* Represents actions specifically related to Forwarded Email Alias.
*/
sealed class ForwardedEmailAlias : UsernameType() {
/**
* Represents the action of selecting a service type option.
*
* @property serviceTypeOption The selected service type option.
*/
data class ServiceTypeOptionSelect(
val serviceTypeOption: GeneratorState
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOption,
) : ForwardedEmailAlias()
}
/**
* Represents actions specifically related to Plus Addressed Email.
*/

View file

@ -874,6 +874,59 @@ class GeneratorScreenTest : BaseComposeTest() {
//endregion Passcode Passphrase Tests
//region Forwarded Email Alias Tests
@Suppress("MaxLineLength")
@Test
fun `in Username_ForwardedEmailAlias state, updating the service type should send ServiceTypeOptionSelect action`() {
updateState(
GeneratorState(
generatedText = "Placeholder",
selectedType = GeneratorState.MainType.Username(
GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias(
selectedServiceType = null,
),
),
),
)
val newServiceType = GeneratorState
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOption
.ANON_ADDY
// Opens the menu
composeTestRule
.onNodeWithContentDescription(label = "Service, null")
.performScrollTo()
.performClick()
// Choose the option from the menu
composeTestRule
.onAllNodesWithText(text = "addy.io")
.onLast()
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
GeneratorAction.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOptionSelect(
newServiceType,
),
)
}
// Make sure dialog is hidden:
composeTestRule
.onNode(isDialog())
.assertDoesNotExist()
}
//endregion Forwarded Email Alias Tests
//region Username Plus Addressed Email Tests
@Suppress("MaxLineLength")

View file

@ -27,6 +27,10 @@ class GeneratorViewModelTest : BaseViewModelTest() {
private val initialUsernameState = createPlusAddressedEmailState()
private val usernameSavedStateHandle = createSavedStateHandleWithState(initialUsernameState)
private val initialForwardedEmailAliasState = createForwardedEmailAliasState()
private val forwardedEmailAliasSavedStateHandle =
createSavedStateHandleWithState(initialForwardedEmailAliasState)
private val initialCatchAllEmailState = createCatchAllEmailState()
private val catchAllEmailSavedStateHandle =
createSavedStateHandleWithState(initialCatchAllEmailState)
@ -895,6 +899,58 @@ class GeneratorViewModelTest : BaseViewModelTest() {
}
}
@Nested
inner class ForwardedEmailAliasActions {
private val defaultForwardedEmailAliasState = createForwardedEmailAliasState()
private lateinit var viewModel: GeneratorViewModel
@BeforeEach
fun setup() {
viewModel =
GeneratorViewModel(forwardedEmailAliasSavedStateHandle, fakeGeneratorRepository)
}
@Test
fun `ServiceTypeOptionSelect should update service type correctly`() = runTest {
val action = GeneratorAction
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOptionSelect(
serviceTypeOption = GeneratorState
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceTypeOption
.ANON_ADDY,
)
viewModel.actionChannel.trySend(action)
val expectedState = defaultForwardedEmailAliasState.copy(
selectedType = GeneratorState.MainType.Username(
selectedType = GeneratorState
.MainType
.Username
.UsernameType
.ForwardedEmailAlias(
selectedServiceType = GeneratorState
.MainType
.Username
.UsernameType
.ForwardedEmailAlias
.ServiceType
.AnonAddy(),
),
),
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
}
@Nested
inner class PlusAddressedEmailActions {
private val defaultPlusAddressedEmailState = createPlusAddressedEmailState()
@ -1088,6 +1144,20 @@ class GeneratorViewModelTest : BaseViewModelTest() {
),
)
private fun createForwardedEmailAliasState(
generatedText: String = "defaultForwardedEmailAlias",
obfuscatedText: String = "defaultObfuscatedText",
): GeneratorState =
GeneratorState(
generatedText = generatedText,
selectedType = GeneratorState.MainType.Username(
GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias(
selectedServiceType = null,
obfuscatedText = obfuscatedText,
),
),
)
private fun createPlusAddressedEmailState(
generatedText: String = "defaultPlusAddressedEmail",
email: String = "defaultEmail",