BIT-529 Added the ability to create custom type fields (#374)

This commit is contained in:
Oleg Semenenko 2023-12-12 12:49:44 -06:00 committed by Álison Fernandes
parent 4e686fcc2e
commit 0148512bf8
22 changed files with 782 additions and 189 deletions

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.res.stringResource
import com.x8bit.bitwarden.R
/**
* Represents a Bitwarden-styled dialog that is used to enter text.
*
* @param title The optional title to show.
* @param textFieldLabel Label for the text field.
* @param onConfirmClick Called when the confirm button is clicked.
* @param onDismissRequest Called when the user attempts to dismiss the dialog.
*/
@Composable
fun BitwardenTextEntryDialog(
title: String?,
textFieldLabel: String,
onConfirmClick: (String) -> Unit,
onDismissRequest: () -> Unit,
) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = stringResource(id = R.string.cancel),
onClick = onDismissRequest,
)
},
confirmButton = {
BitwardenTextButton(
label = stringResource(id = R.string.ok),
onClick = { onConfirmClick(text) },
)
},
title = title?.let {
{
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
)
}
},
text = {
BitwardenTextField(
label = textFieldLabel,
value = text,
onValueChange = { text = it },
)
},
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}

View file

@ -0,0 +1,242 @@
package com.x8bit.bitwarden.ui.vault.feature.additem
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenRowOfActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* The UI element used to display custom field items.
*
* @param customField The field that is to be displayed.
* @param onCustomFieldValueChange Invoked when the user changes the value.
* @param modifier Modifier for the UI elements.
* @param supportedLinkedTypes The supported linked types for the vault item.
*/
@Composable
@Suppress("LongMethod")
fun AddEditCustomField(
customField: VaultAddItemState.Custom,
onCustomFieldValueChange: (VaultAddItemState.Custom) -> Unit,
modifier: Modifier = Modifier,
supportedLinkedTypes: ImmutableList<VaultLinkedFieldType> = persistentListOf(),
) {
when (customField) {
is VaultAddItemState.Custom.BooleanField -> {
CustomFieldBoolean(
label = customField.name,
value = customField.value,
onValueChanged = { onCustomFieldValueChange(customField.copy(value = it)) },
modifier = modifier,
)
}
is VaultAddItemState.Custom.HiddenField -> {
CustomFieldHiddenField(
customField.name,
customField.value,
onValueChanged = {
onCustomFieldValueChange(customField.copy(value = it))
},
modifier = modifier,
)
}
is VaultAddItemState.Custom.LinkedField -> {
CustomFieldLinkedField(
selectedOption = customField.vaultLinkedFieldType,
supportedLinkedTypes = supportedLinkedTypes,
onValueChanged = {
onCustomFieldValueChange(customField.copy(vaultLinkedFieldType = it))
},
modifier = modifier,
)
}
is VaultAddItemState.Custom.TextField -> {
CustomFieldTextField(
label = customField.name,
value = customField.value,
onValueChanged = { onCustomFieldValueChange(customField.copy(value = it)) },
modifier = modifier,
)
}
}
}
/**
* A UI element that is used to display custom field boolean fields.
*/
@Composable
private fun CustomFieldBoolean(
label: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Row(
modifier = modifier
.semantics(mergeDescendants = true) {}
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
BitwardenWideSwitch(
label = label,
isChecked = value,
onCheckedChange = onValueChanged,
modifier = Modifier.weight(1f),
)
BitwardenRowOfActions(
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_settings),
contentDescription = stringResource(id = R.string.edit),
),
onClick = {
// TODO add support for custom field actions (BIT-540)
showNotYetImplementedToast(context = context)
},
)
},
)
}
}
/**
* A UI element that is used to display custom field hidden fields.
*/
@Composable
private fun CustomFieldHiddenField(
label: String,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
BitwardenPasswordFieldWithActions(
label = label,
value = value,
onValueChange = onValueChanged,
singleLine = true,
modifier = modifier,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_settings),
contentDescription = stringResource(id = R.string.edit),
),
onClick = {
// TODO Add support for custom field actions (BIT-540)
showNotYetImplementedToast(context = context)
},
)
},
)
}
/**
* A UI element that is used to display custom field text fields.
*/
@Composable
private fun CustomFieldTextField(
label: String,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
BitwardenTextFieldWithActions(
label = label,
value = value,
onValueChange = onValueChanged,
singleLine = true,
modifier = modifier,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_settings),
contentDescription = stringResource(id = R.string.edit),
),
onClick = {
// TODO add support for custom field actions (BIT-540)
showNotYetImplementedToast(context = context)
},
)
},
)
}
/**
* A UI element that is used to display custom field linked fields.
*/
@Composable
private fun CustomFieldLinkedField(
selectedOption: VaultLinkedFieldType,
onValueChanged: (VaultLinkedFieldType) -> Unit,
modifier: Modifier = Modifier,
label: String = "",
supportedLinkedTypes: ImmutableList<VaultLinkedFieldType> = persistentListOf(),
) {
val context = LocalContext.current
val possibleTypesWithStrings = supportedLinkedTypes.associateWith { it.label.invoke() }
Row(
modifier = modifier
.semantics(mergeDescendants = true) {}
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
BitwardenMultiSelectButton(
label = label,
options = supportedLinkedTypes.map { it.label.invoke() }.toImmutableList(),
selectedOption = selectedOption.label.invoke(),
onOptionSelected = { selectedType ->
possibleTypesWithStrings.forEach {
if (it.value == selectedType) {
onValueChanged(it.key)
}
}
},
modifier = Modifier.weight(1f),
)
BitwardenRowOfActions(
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_settings),
contentDescription = stringResource(id = R.string.edit),
),
onClick = {
// TODO add support for custom field actions (BIT-540)
showNotYetImplementedToast(context = context)
},
)
},
)
}
}

View file

@ -0,0 +1,79 @@
package com.x8bit.bitwarden.ui.vault.feature.additem
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.Modifier
import androidx.compose.ui.res.stringResource
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialogRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextEntryDialog
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* A UI element that is used by the user to add a custom field item.
*
* @param options The types that are to be chosen by the user.
* @param onFinishNamingClick Invoked when the user finishes naming the item.
*/
@Suppress("LongMethod")
@Composable
fun AddEditCustomFieldsButton(
onFinishNamingClick: (CustomFieldType, String) -> Unit,
modifier: Modifier = Modifier,
options: ImmutableList<CustomFieldType> = persistentListOf(
CustomFieldType.TEXT,
CustomFieldType.HIDDEN,
CustomFieldType.BOOLEAN,
CustomFieldType.LINKED,
),
) {
var shouldShowChooserDialog by remember { mutableStateOf(false) }
var shouldShowNameDialog by remember { mutableStateOf(false) }
var customFieldType: CustomFieldType by remember { mutableStateOf(CustomFieldType.TEXT) }
var customFieldName: String by remember { mutableStateOf("") }
if (shouldShowChooserDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.select_type_field),
onDismissRequest = { shouldShowChooserDialog = false },
) {
options.forEach { type ->
BitwardenBasicDialogRow(
text = type.typeText.invoke(),
onClick = {
shouldShowChooserDialog = false
shouldShowNameDialog = true
customFieldType = type
},
)
}
}
}
if (shouldShowNameDialog) {
BitwardenTextEntryDialog(
title = stringResource(id = R.string.custom_field_name),
textFieldLabel = stringResource(id = R.string.name),
onDismissRequest = { shouldShowNameDialog = false },
onConfirmClick = {
shouldShowNameDialog = false
customFieldName = it
onFinishNamingClick(customFieldType, customFieldName)
},
)
}
BitwardenFilledTonalButton(
label = stringResource(id = R.string.new_custom_field),
onClick = { shouldShowChooserDialog = true },
modifier = modifier,
)
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -24,6 +25,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitchWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
@ -245,11 +248,24 @@ fun LazyListScope.addEditLoginItems(
)
}
items(state.customFieldData) { customItem ->
AddEditCustomField(
customItem,
onCustomFieldValueChange = loginItemTypeHandlers.onCustomFieldValueChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
supportedLinkedTypes = persistentListOf(
VaultLinkedFieldType.PASSWORD,
VaultLinkedFieldType.USERNAME,
),
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.new_custom_field),
onClick = loginItemTypeHandlers.onAddNewCustomFieldClick,
AddEditCustomFieldsButton(
onFinishNamingClick = loginItemTypeHandlers.onAddNewCustomFieldClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -13,12 +14,13 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitchWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
@ -132,11 +134,25 @@ fun LazyListScope.addEditSecureNotesItems(
)
}
items(state.customFieldData) { customItem ->
AddEditCustomField(
customItem,
onCustomFieldValueChange = secureNotesTypeHandlers.onCustomFieldValueChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.new_custom_field),
onClick = secureNotesTypeHandlers.onAddNewCustomFieldClick,
AddEditCustomFieldsButton(
onFinishNamingClick = secureNotesTypeHandlers.onAddNewCustomFieldClick,
options = persistentListOf(
CustomFieldType.TEXT,
CustomFieldType.HIDDEN,
CustomFieldType.BOOLEAN,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),

View file

@ -16,8 +16,11 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemAction.ItemType.SecureNotesType.TooltipClick.toCustomField
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.UUID
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -279,7 +283,11 @@ class VaultAddItemViewModel @Inject constructor(
}
is VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick -> {
handleLoginAddNewCustomFieldClick()
handleLoginAddNewCustomFieldClick(action)
}
is VaultAddItemAction.ItemType.LoginType.CustomFieldValueChange -> {
handleLoginCustomFieldValueChange(action)
}
}
}
@ -426,12 +434,29 @@ class VaultAddItemViewModel @Inject constructor(
}
}
private fun handleLoginAddNewCustomFieldClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Add New Custom Field",
),
private fun handleLoginAddNewCustomFieldClick(
action: VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick,
) {
val newCustomData: VaultAddItemState.Custom =
action.customFieldType.toCustomField(action.name)
updateLoginContent { loginType ->
loginType.copy(customFieldData = loginType.customFieldData + newCustomData)
}
}
private fun handleLoginCustomFieldValueChange(
action: VaultAddItemAction.ItemType.LoginType.CustomFieldValueChange,
) {
updateLoginContent { login ->
login.copy(
customFieldData = login.customFieldData.map { customField ->
if (customField.itemId == action.customField.itemId) {
action.customField
} else {
customField
}
},
)
}
}
@ -473,7 +498,11 @@ class VaultAddItemViewModel @Inject constructor(
}
is VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick -> {
handleSecureNoteAddNewCustomFieldClick()
handleSecureNoteAddNewCustomFieldClick(action)
}
is VaultAddItemAction.ItemType.SecureNotesType.CustomFieldValueChange -> {
handleSecureNoteCustomFieldValueChange(action)
}
}
}
@ -535,14 +564,32 @@ class VaultAddItemViewModel @Inject constructor(
)
}
private fun handleSecureNoteAddNewCustomFieldClick() {
// TODO Implement custom text fields (BIT-529)
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Not yet implemented",
),
private fun handleSecureNoteAddNewCustomFieldClick(
action: VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick,
) {
val newCustomData: VaultAddItemState.Custom =
action.customFieldType.toCustomField(action.name)
updateSecureNoteContent { secureNotesType ->
secureNotesType.copy(customFieldData = secureNotesType.customFieldData + newCustomData)
}
}
private fun handleSecureNoteCustomFieldValueChange(
action: VaultAddItemAction.ItemType.SecureNotesType.CustomFieldValueChange,
) {
updateSecureNoteContent { secureNote ->
secureNote.copy(
customFieldData = secureNote.customFieldData.map { customField ->
if (customField.itemId == action.customField.itemId) {
action.customField
} else {
customField
}
},
)
}
}
//endregion Secure Notes Item Type Handlers
@ -810,6 +857,7 @@ data class VaultAddItemState(
val folderName: Text = DEFAULT_FOLDER,
val favorite: Boolean = false,
override val masterPasswordReprompt: Boolean = false,
val customFieldData: List<Custom> = emptyList(),
val notes: String = "",
// TODO: Update this property to get available owners from the data layer (BIT-501)
val availableFolders: List<Text> = listOf(
@ -876,6 +924,7 @@ data class VaultAddItemState(
"Folder 2".asText(),
"Folder 3".asText(),
),
val customFieldData: List<Custom> = emptyList(),
override val ownership: String = DEFAULT_OWNERSHIP,
override val availableOwners: List<String> = listOf("a@b.com", "c@d.com"),
) : Content() {
@ -889,6 +938,58 @@ data class VaultAddItemState(
}
}
/**
* This Models the Custom field type chosen by the user.
*/
@Parcelize
sealed class Custom : Parcelable {
/**
* The itemId that is used to identify the Custom item on updates.
*/
abstract val itemId: String
/**
* Represents the data for displaying a custom text field.
*/
@Parcelize
data class TextField(
override val itemId: String,
val name: String,
val value: String,
) : Custom()
/**
* Represents the data for displaying a custom hidden text field.
*/
@Parcelize
data class HiddenField(
override val itemId: String,
val name: String,
val value: String,
) : Custom()
/**
* Represents the data for displaying a custom boolean property field.
*/
@Parcelize
data class BooleanField(
override val itemId: String,
val name: String,
val value: Boolean,
) : Custom()
/**
* Represents the data for displaying a custom linked field.
*/
@Parcelize
data class LinkedField(
override val itemId: String,
val name: String,
val vaultLinkedFieldType: VaultLinkedFieldType,
) : Custom()
}
/**
* Displays a dialog.
*/
@ -1029,6 +1130,21 @@ sealed class VaultAddItemAction {
*/
data class OwnershipChange(val ownership: String) : LoginType()
/**
* Represents the action to add a new custom field.
*/
data class AddNewCustomFieldClick(
val customFieldType: CustomFieldType,
val name: String,
) : LoginType()
/**
* Fired when the custom field data is changed.
*/
data class CustomFieldValueChange(
val customField: VaultAddItemState.Custom,
) : LoginType()
/**
* Represents the action to open the username generator.
*/
@ -1063,11 +1179,6 @@ sealed class VaultAddItemAction {
* Represents the action to open tooltip
*/
data object TooltipClick : LoginType()
/**
* Represents the action to add a new custom field.
*/
data object AddNewCustomFieldClick : LoginType()
}
/**
@ -1127,7 +1238,17 @@ sealed class VaultAddItemAction {
/**
* Represents the action to add a new custom field.
*/
data object AddNewCustomFieldClick : SecureNotesType()
data class AddNewCustomFieldClick(
val customFieldType: CustomFieldType,
val name: String,
) : SecureNotesType()
/**
* Fired when the custom field data is changed.
*/
data class CustomFieldValueChange(
val customField: VaultAddItemState.Custom,
) : SecureNotesType()
}
}
@ -1156,4 +1277,43 @@ sealed class VaultAddItemAction {
val updateCipherResult: UpdateCipherResult,
) : Internal()
}
/**
* An extension function for adding custom field types.
*/
fun CustomFieldType.toCustomField(name: String): VaultAddItemState.Custom {
return when (this) {
CustomFieldType.BOOLEAN -> {
VaultAddItemState.Custom.BooleanField(
itemId = UUID.randomUUID().toString(),
name = name,
value = false,
)
}
CustomFieldType.LINKED -> {
VaultAddItemState.Custom.LinkedField(
itemId = UUID.randomUUID().toString(),
name = name,
vaultLinkedFieldType = VaultLinkedFieldType.USERNAME,
)
}
CustomFieldType.HIDDEN -> {
VaultAddItemState.Custom.HiddenField(
itemId = UUID.randomUUID().toString(),
name = name,
value = "",
)
}
CustomFieldType.TEXT -> {
VaultAddItemState.Custom.TextField(
itemId = UUID.randomUUID().toString(),
name = name,
value = "",
)
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.additem
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
/**
* A collection of handler functions specifically tailored for managing actions
@ -28,6 +29,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
* @property onTooltipClick Handles the action when the tooltip button is clicked.
* @property onAddNewCustomFieldClick Handles the action when the add new custom field
* button is clicked.
* @property onCustomFieldValueChange Handles the action when the field's value changes
*/
@Suppress("LongParameterList")
class VaultAddLoginItemTypeHandlers(
@ -47,7 +49,8 @@ class VaultAddLoginItemTypeHandlers(
val onUriSettingsClick: () -> Unit,
val onAddNewUriClick: () -> Unit,
val onTooltipClick: () -> Unit,
val onAddNewCustomFieldClick: () -> Unit,
val onAddNewCustomFieldClick: (CustomFieldType, String) -> Unit,
val onCustomFieldValueChange: (VaultAddItemState.Custom) -> Unit,
) {
companion object {
@ -135,9 +138,19 @@ class VaultAddLoginItemTypeHandlers(
onTooltipClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.TooltipClick)
},
onAddNewCustomFieldClick = {
onAddNewCustomFieldClick = { customFieldType, name ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick,
VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick(
customFieldType = customFieldType,
name = name,
),
)
},
onCustomFieldValueChange = { customField ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.CustomFieldValueChange(
customField = customField,
),
)
},
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.additem
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
/**
* A collection of handler functions specifically tailored for managing actions
@ -16,6 +17,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
* @property onTooltipClick Handles the action when the tooltip button is clicked.
* @property onAddNewCustomFieldClick Handles the action when the add new custom field
* button is clicked.
* @property onCustomFieldValueChange Handles the action when the field's value changes
*/
@Suppress("LongParameterList")
class VaultAddSecureNotesItemTypeHandlers(
@ -26,7 +28,8 @@ class VaultAddSecureNotesItemTypeHandlers(
val onNotesTextChange: (String) -> Unit,
val onOwnershipTextChange: (String) -> Unit,
val onTooltipClick: () -> Unit,
val onAddNewCustomFieldClick: () -> Unit,
val onAddNewCustomFieldClick: (CustomFieldType, String) -> Unit,
val onCustomFieldValueChange: (VaultAddItemState.Custom) -> Unit,
) {
companion object {
@ -76,9 +79,19 @@ class VaultAddSecureNotesItemTypeHandlers(
VaultAddItemAction.ItemType.SecureNotesType.TooltipClick,
)
},
onAddNewCustomFieldClick = {
onAddNewCustomFieldClick = { newCustomFieldType, name ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick,
VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick(
newCustomFieldType,
name,
),
)
},
onCustomFieldValueChange = { newValue ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.SecureNotesType.CustomFieldValueChange(
newValue,
),
)
},
)

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.ui.vault.feature.additem.model
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* The Enum representing the Custom Field type that is being added by the user.
*/
enum class CustomFieldType(val typeText: Text) {
LINKED(R.string.field_type_linked.asText()),
HIDDEN(R.string.field_type_hidden.asText()),
BOOLEAN(R.string.field_type_boolean.asText()),
TEXT(R.string.field_type_text.asText()),
}

View file

@ -3,9 +3,13 @@ package com.x8bit.bitwarden.ui.vault.feature.additem.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType.Companion.fromId
import java.util.UUID
/**
* Transforms [CipherView] into [VaultAddItemState.ViewState].
@ -30,6 +34,7 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState =
ownership = "",
// TODO: Update this property to pull available owners from data layer (BIT-501)
availableOwners = emptyList(),
customFieldData = this.fields.orEmpty().map { it.toCustomField() },
)
}
@ -47,6 +52,7 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState =
ownership = "",
// TODO: Update this property to pull available owners from data layer (BIT-501)
availableOwners = emptyList(),
customFieldData = this.fields.orEmpty().map { it.toCustomField() },
)
}
@ -58,3 +64,30 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState =
message = "Not yet implemented.".asText(),
)
}
private fun FieldView.toCustomField() =
when (this.type) {
FieldType.TEXT -> VaultAddItemState.Custom.TextField(
itemId = UUID.randomUUID().toString(),
name = this.name.orEmpty(),
value = this.value.orEmpty(),
)
FieldType.HIDDEN -> VaultAddItemState.Custom.HiddenField(
itemId = UUID.randomUUID().toString(),
name = this.name.orEmpty(),
value = this.value.orEmpty(),
)
FieldType.BOOLEAN -> VaultAddItemState.Custom.BooleanField(
itemId = UUID.randomUUID().toString(),
name = this.name.orEmpty(),
value = this.value.toBoolean(),
)
FieldType.LINKED -> VaultAddItemState.Custom.LinkedField(
itemId = UUID.randomUUID().toString(),
name = this.name.orEmpty(),
vaultLinkedFieldType = fromId(requireNotNull(this.linkedId)),
)
}

View file

@ -257,7 +257,7 @@ private fun CustomField(
is VaultItemState.ViewState.Content.Custom.LinkedField -> {
BitwardenTextField(
label = customField.name,
value = customField.type.label(),
value = customField.vaultLinkedFieldType.label.invoke(),
leadingIconResource = IconResource(
iconPainter = painterResource(id = R.drawable.ic_linked),
contentDescription = stringResource(id = R.string.field_type_linked),

View file

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@ -550,22 +551,9 @@ data class VaultItemState(
*/
@Parcelize
data class LinkedField(
private val id: UInt,
val vaultLinkedFieldType: VaultLinkedFieldType,
val name: String,
) : Custom() {
val type: Type get() = Type.values().first { it.id == id }
/**
* Represents the types linked fields.
*/
enum class Type(
val id: UInt,
val label: Text,
) {
USERNAME(id = 100.toUInt(), label = R.string.username.asText()),
PASSWORD(id = 101.toUInt(), label = R.string.password.asText()),
}
}
) : Custom()
}
}
}

View file

@ -9,6 +9,7 @@ import com.bitwarden.core.LoginUriView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.orZeroWidthSpace
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import java.time.format.DateTimeFormatter
@ -82,7 +83,7 @@ private fun FieldView.toCustomField(): VaultItemState.ViewState.Content.Custom =
)
FieldType.LINKED -> VaultItemState.ViewState.Content.Custom.LinkedField(
id = requireNotNull(linkedId),
vaultLinkedFieldType = VaultLinkedFieldType.fromId(requireNotNull(linkedId)),
name = name.orEmpty(),
)
}

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.SecureNoteType
@ -144,8 +146,7 @@ private fun VaultAddItemState.ViewState.Content.Login.toLoginCipherView(): Ciphe
// TODO Use real organization ID (BIT-780)
organizationId = this.originalCipher?.organizationId,
reprompt = this.toCipherRepromptType(),
// TODO Implement custom fields (BIT-529)
fields = null,
fields = this.customFieldData.map { it.toFieldView() },
)
/**
@ -183,8 +184,7 @@ private fun VaultAddItemState.ViewState.Content.SecureNotes.toSecureNotesCipherV
// TODO Use real organization ID (BIT-780)
organizationId = this.originalCipher?.organizationId,
reprompt = this.toCipherRepromptType(),
// TODO Implement custom fields (BIT-529)
fields = null,
fields = this.customFieldData.map { it.toFieldView() },
)
/**
@ -205,3 +205,45 @@ private fun VaultAddItemState.ViewState.Content.toCipherRepromptType(): CipherRe
} else {
CipherRepromptType.NONE
}
/**
* Transforms [VaultAddItemState.Custom into [FieldView].
*/
private fun VaultAddItemState.Custom.toFieldView(): FieldView =
when (val item = this) {
is VaultAddItemState.Custom.BooleanField -> {
FieldView(
name = item.name,
value = item.value.toString(),
type = FieldType.BOOLEAN,
linkedId = null,
)
}
is VaultAddItemState.Custom.HiddenField -> {
FieldView(
name = item.name,
value = item.value,
type = FieldType.HIDDEN,
linkedId = null,
)
}
is VaultAddItemState.Custom.LinkedField -> {
FieldView(
name = item.name,
value = null,
type = FieldType.LINKED,
linkedId = item.vaultLinkedFieldType.id,
)
}
is VaultAddItemState.Custom.TextField -> {
FieldView(
name = item.name,
value = item.value,
type = FieldType.TEXT,
linkedId = null,
)
}
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.vault.model
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* Represents the types for linked fields.
*
* @param id The ID for the linked field.
* @param label A human-readable label for the linked field.
*/
enum class VaultLinkedFieldType(
val id: UInt,
val label: Text,
) {
USERNAME(id = 100.toUInt(), label = R.string.username.asText()),
PASSWORD(id = 101.toUInt(), label = R.string.password.asText()),
;
companion object {
/**
* Helper function to get the LinkedCustomFieldType from the id
*/
fun fromId(id: UInt): VaultLinkedFieldType =
VaultLinkedFieldType.entries.first { it.id == id }
}
}

View file

@ -542,18 +542,6 @@ class VaultAddItemScreenTest : BaseComposeTest() {
.assertTextContains("NewNote")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking New Custom Field button should trigger AddNewCustomFieldClick`() {
composeTestRule
.onNodeWithTextAfterScroll(text = "New custom field")
.performClick()
verify {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick)
}
}
@Test
fun `in ItemType_Login state clicking a Ownership option should send OwnershipChange action`() {
// Opens the menu
@ -793,22 +781,6 @@ class VaultAddItemScreenTest : BaseComposeTest() {
.assertTextContains("NewNote")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state clicking New Custom Field button should trigger AddNewCustomFieldClick`() {
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule
.onNodeWithTextAfterScroll(text = "New custom field")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state clicking a Ownership option should send OwnershipChange action`() {

View file

@ -587,21 +587,6 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
assertEquals(VaultAddItemEvent.ShowToast("Tooltip"), awaitItem())
}
}
@Test
fun `AddNewCustomFieldClick should emit ShowToast with 'Add New Custom Field' message`() =
runTest {
val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(
VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Add New Custom Field"), awaitItem())
}
}
}
@Nested
@ -734,19 +719,6 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
assertEquals(VaultAddItemEvent.ShowToast("Not yet implemented"), awaitItem())
}
}
@Test
fun `AddNewCustomFieldClick should emit ShowToast with 'Add New Custom Field' message`() =
runTest {
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(
VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Not yet implemented"), awaitItem())
}
}
}
@Suppress("LongParameterList")

View file

@ -4,8 +4,6 @@ import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
@ -65,6 +63,7 @@ class CipherViewExtensionsTest {
ownership = "",
availableFolders = emptyList(),
availableOwners = emptyList(),
customFieldData = emptyList(),
),
result,
)
@ -87,6 +86,7 @@ class CipherViewExtensionsTest {
ownership = "",
availableFolders = emptyList(),
availableOwners = emptyList(),
customFieldData = emptyList(),
),
result,
)
@ -113,38 +113,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
viewPassword = false,
localData = null,
attachments = null,
fields = listOf(
FieldView(
name = "text",
value = "value",
type = FieldType.TEXT,
linkedId = null,
),
FieldView(
name = "hidden",
value = "value",
type = FieldType.HIDDEN,
linkedId = null,
),
FieldView(
name = "boolean",
value = "true",
type = FieldType.BOOLEAN,
linkedId = null,
),
FieldView(
name = "linked username",
value = null,
type = FieldType.LINKED,
linkedId = 100U,
),
FieldView(
name = "linked password",
value = null,
type = FieldType.LINKED,
linkedId = 101U,
),
),
fields = emptyList(),
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -712,11 +713,11 @@ private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked username",
id = 100U,
vaultLinkedFieldType = VaultLinkedFieldType.USERNAME,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked password",
id = 101U,
vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD,
),
),
requiresReprompt = true,

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.util.DEFAULT_EMPTY_LOGIN_VIEW_STATE
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -626,11 +627,11 @@ private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked username",
id = 100U,
vaultLinkedFieldType = VaultLinkedFieldType.USERNAME,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked password",
id = 101U,
vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD,
),
),
requiresReprompt = true,

View file

@ -9,6 +9,7 @@ import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@ -192,11 +193,11 @@ val DEFAULT_FULL_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked username",
id = 100U,
vaultLinkedFieldType = VaultLinkedFieldType.USERNAME,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked password",
id = 101U,
vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD,
),
),
requiresReprompt = true,

View file

@ -3,8 +3,6 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
@ -159,7 +157,7 @@ class VaultDataExtensionsTest {
viewPassword = true,
localData = null,
attachments = null,
fields = null,
fields = emptyList(),
passwordHistory = null,
creationDate = Instant.MIN,
deletedDate = null,
@ -183,6 +181,7 @@ class VaultDataExtensionsTest {
masterPasswordReprompt = false,
notes = "mockNotes-1",
ownership = "mockOwnership-1",
customFieldData = emptyList(),
)
val result = loginItemType.toCipherView()
@ -208,7 +207,7 @@ class VaultDataExtensionsTest {
),
favorite = true,
reprompt = CipherRepromptType.NONE,
fields = null,
fields = emptyList(),
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",
@ -256,7 +255,7 @@ class VaultDataExtensionsTest {
viewPassword = true,
localData = null,
attachments = null,
fields = null,
fields = emptyList(),
passwordHistory = null,
creationDate = Instant.MIN,
deletedDate = null,
@ -277,6 +276,7 @@ class VaultDataExtensionsTest {
masterPasswordReprompt = true,
notes = "mockNotes-1",
ownership = "mockOwnership-1",
customFieldData = emptyList(),
)
val result = secureNotesItemType.toCipherView()
@ -288,7 +288,7 @@ class VaultDataExtensionsTest {
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(SecureNoteType.GENERIC),
reprompt = CipherRepromptType.PASSWORD,
fields = null,
fields = emptyList(),
),
result,
)
@ -315,38 +315,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
viewPassword = false,
localData = null,
attachments = null,
fields = listOf(
FieldView(
name = "text",
value = "value",
type = FieldType.TEXT,
linkedId = null,
),
FieldView(
name = "hidden",
value = "value",
type = FieldType.HIDDEN,
linkedId = null,
),
FieldView(
name = "boolean",
value = "true",
type = FieldType.BOOLEAN,
linkedId = null,
),
FieldView(
name = "linked username",
value = null,
type = FieldType.LINKED,
linkedId = 100U,
),
FieldView(
name = "linked password",
value = null,
type = FieldType.LINKED,
linkedId = 101U,
),
),
fields = emptyList(),
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",