BIT-514: View identity item UI (#461)

This commit is contained in:
Ramsey Smith
2024-06-20 17:08:07 +01:00
committed by Álison Fernandes
parent cd1f703ba7
commit 0c05855e6b
16 changed files with 1066 additions and 369 deletions
@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.ui.platform.base.util
/**
* Returns null if all entries in a given list are equal to a provided [value], otherwise
* the original list is returned.
*/
fun <T> List<T>.nullIfAllEqual(value: T): List<T>? =
if (all { it == value }) {
null
} else {
this
}
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.runtime.Composable
@@ -154,3 +156,10 @@ fun String.toHexColorRepresentation(): String {
val blue = ((hash and 0xFF0000) shr 16).toTwoDigitHexString()
return "#ff$red$green$blue"
}
/**
* Returns a copy of this string having its first letter titlecased using the rules of the specified
* [locale], or the original string if it's empty or already starts with a title case letter.
*/
fun String.capitalize(locale: Locale = Locale.getDefault()): String =
replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
@@ -28,6 +28,7 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState =
CipherType.SECURE_NOTE -> VaultAddItemState.ViewState.Content.ItemType.SecureNotes
CipherType.CARD -> VaultAddItemState.ViewState.Content.ItemType.Card
CipherType.IDENTITY -> VaultAddItemState.ViewState.Content.ItemType.Identity(
selectedTitle = identity?.title.toTitleOrDefault(),
firstName = identity?.firstName.orEmpty(),
middleName = identity?.middleName.orEmpty(),
lastName = identity?.lastName.orEmpty(),
@@ -89,3 +90,10 @@ private fun FieldView.toCustomField() =
vaultLinkedFieldType = fromId(requireNotNull(this.linkedId)),
)
}
@Suppress("MaxLineLength")
private fun String?.toTitleOrDefault(): VaultAddItemState.ViewState.Content.ItemType.Identity.Title =
VaultAddItemState.ViewState.Content.ItemType.Identity.Title
.entries
.find { it.name == this }
?: VaultAddItemState.ViewState.Content.ItemType.Identity.Title.MR
@@ -0,0 +1,101 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
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
/**
* Custom Field UI common for all item types.
*/
@Suppress("LongMethod", "MaxLineLength")
@Composable
fun CustomField(
customField: VaultItemState.ViewState.Content.Common.Custom,
onCopyCustomHiddenField: (String) -> Unit,
onCopyCustomTextField: (String) -> Unit,
onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Common.Custom.HiddenField, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
when (customField) {
is VaultItemState.ViewState.Content.Common.Custom.BooleanField -> {
BitwardenWideSwitch(
label = customField.name,
isChecked = customField.value,
readOnly = true,
onCheckedChange = { },
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Common.Custom.HiddenField -> {
BitwardenPasswordFieldWithActions(
label = customField.name,
value = customField.value,
showPasswordChange = { onShowHiddenFieldClick(customField, it) },
showPassword = customField.isVisible,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = {
onCopyCustomHiddenField(customField.value)
},
)
}
},
)
}
is VaultItemState.ViewState.Content.Common.Custom.LinkedField -> {
BitwardenTextField(
label = customField.name,
value = customField.vaultLinkedFieldType.label.invoke(),
leadingIconResource = IconResource(
iconPainter = painterResource(id = R.drawable.ic_linked),
contentDescription = stringResource(id = R.string.field_type_linked),
),
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Common.Custom.TextField -> {
BitwardenTextFieldWithActions(
label = customField.name,
value = customField.value,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = { onCopyCustomTextField(customField.value) },
)
}
},
)
}
}
}
@@ -0,0 +1,227 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
/**
* The top level content UI state for the [VaultItemScreen] when viewing a Identity cipher.
*/
@Suppress("LongMethod")
@Composable
fun VaultItemIdentityContent(
identityState: VaultItemState.ViewState.Content.ItemType.Identity,
commonState: VaultItemState.ViewState.Content.Common,
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item {
BitwardenListHeaderText(
label = stringResource(id = R.string.item_information),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.name),
value = commonState.name,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
identityState.identityName?.let { identityName ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.identity_name),
value = identityName,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.username?.let { username ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.username),
value = username,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.company?.let { company ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.company),
value = company,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.ssn?.let { ssn ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.ssn),
value = ssn,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.passportNumber?.let { passportNumber ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.passport_number),
value = passportNumber,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.licenseNumber?.let { licenseNumber ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.license_number),
value = licenseNumber,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.email?.let { email ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.email),
value = email,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.phone?.let { phone ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.phone),
value = phone,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
identityState.address?.let { address ->
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.address),
value = address,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(customFields) { customField ->
Spacer(modifier = Modifier.height(8.dp))
CustomField(
customField = customField,
onCopyCustomHiddenField = vaultCommonItemTypeHandlers.onCopyCustomHiddenField,
onCopyCustomTextField = vaultCommonItemTypeHandlers.onCopyCustomTextField,
onShowHiddenFieldClick = vaultCommonItemTypeHandlers.onShowHiddenFieldClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
text = commonState.lastUpdated,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@@ -23,7 +23,6 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
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.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
@@ -172,7 +171,7 @@ fun VaultItemLoginContent(
item {
Spacer(modifier = Modifier.height(24.dp))
UpdateText(
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
text = commonState.lastUpdated,
modifier = Modifier
@@ -183,7 +182,7 @@ fun VaultItemLoginContent(
loginItemState.passwordRevisionDate?.let { revisionDate ->
item {
UpdateText(
VaultItemUpdateText(
header = "${stringResource(id = R.string.date_password_updated)}: ",
text = revisionDate,
modifier = Modifier
@@ -212,91 +211,6 @@ fun VaultItemLoginContent(
}
}
@Suppress("LongMethod", "MaxLineLength")
@Composable
private fun CustomField(
customField: VaultItemState.ViewState.Content.Common.Custom,
onCopyCustomHiddenField: (String) -> Unit,
onCopyCustomTextField: (String) -> Unit,
onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Common.Custom.HiddenField, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
when (customField) {
is VaultItemState.ViewState.Content.Common.Custom.BooleanField -> {
BitwardenWideSwitch(
label = customField.name,
isChecked = customField.value,
readOnly = true,
onCheckedChange = { },
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Common.Custom.HiddenField -> {
BitwardenPasswordFieldWithActions(
label = customField.name,
value = customField.value,
showPasswordChange = { onShowHiddenFieldClick(customField, it) },
showPassword = customField.isVisible,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = {
onCopyCustomHiddenField(customField.value)
},
)
}
},
)
}
is VaultItemState.ViewState.Content.Common.Custom.LinkedField -> {
BitwardenTextField(
label = customField.name,
value = customField.vaultLinkedFieldType.label.invoke(),
leadingIconResource = IconResource(
iconPainter = painterResource(id = R.drawable.ic_linked),
contentDescription = stringResource(id = R.string.field_type_linked),
),
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Common.Custom.TextField -> {
BitwardenTextFieldWithActions(
label = customField.name,
value = customField.value,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = { onCopyCustomTextField(customField.value) },
)
}
},
)
}
}
}
@Composable
private fun NotesField(
notes: String,
@@ -393,29 +307,6 @@ private fun TotpField(
}
}
@Composable
private fun UpdateText(
header: String,
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.semantics(mergeDescendants = true) { },
) {
Text(
text = header,
style = LocalNonMaterialTypography.current.labelMediumProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun UriField(
uriData: VaultItemState.ViewState.Content.ItemType.Login.UriData,
@@ -197,9 +197,9 @@ private fun VaultItemContent(
VaultItemLoginContent(
commonState = viewState.common,
loginItemState = viewState.type,
modifier = modifier,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
vaultLoginItemTypeHandlers = vaultLoginItemTypeHandlers,
modifier = modifier,
)
}
@@ -208,7 +208,12 @@ private fun VaultItemContent(
}
is VaultItemState.ViewState.Content.ItemType.Identity -> {
// TODO UI for viewing Identity BIT-514
VaultItemIdentityContent(
commonState = viewState.common,
identityState = viewState.type,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.ItemType.SecureNote -> {
@@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
/**
* Update Text UI common for all item types.
*/
@Composable
fun VaultItemUpdateText(
header: String,
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.semantics(mergeDescendants = true) { },
) {
Text(
text = header,
style = LocalNonMaterialTypography.current.labelMediumProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -612,8 +612,28 @@ data class VaultItemState(
/**
* Represents the `Identity` item type.
*
* @property identityName The name for the identity.
* @property username The username for the identity.
* @property company The company associated with the identity.
* @property ssn The SSN for the identity.
* @property passportNumber The passport number for the identity.
* @property licenseNumber The license number for the identity.
* @property email The email for the identity.
* @property phone The phone number for the identity.
* @property address The address for the identity.
*/
data object Identity : ItemType()
data class Identity(
val identityName: String?,
val username: String?,
val company: String?,
val ssn: String?,
val passportNumber: String?,
val licenseNumber: String?,
val email: String?,
val phone: String?,
val address: String?,
) : ItemType()
/**
* Represents the `Card` item type.
@@ -5,8 +5,12 @@ 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.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.capitalize
import com.x8bit.bitwarden.ui.platform.base.util.nullIfAllEqual
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.ui.platform.base.util.orZeroWidthSpace
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
@@ -63,7 +67,17 @@ fun CipherView.toViewState(
}
CipherType.IDENTITY -> {
VaultItemState.ViewState.Content.ItemType.Identity
VaultItemState.ViewState.Content.ItemType.Identity(
username = identity?.username,
identityName = identity?.identityName,
company = identity?.company,
ssn = identity?.ssn,
passportNumber = identity?.passportNumber,
licenseNumber = identity?.licenseNumber,
email = identity?.email,
phone = identity?.phone,
address = identity?.identityAddress,
)
}
},
)
@@ -100,3 +114,28 @@ private fun LoginUriView.toUriData() =
isCopyable = !uri.isNullOrBlank(),
isLaunchable = !uri.isNullOrBlank(),
)
private val IdentityView.identityAddress: String?
get() = listOfNotNull(
address1,
address2,
address3,
listOf(city ?: "-", state ?: "-", postalCode ?: "-")
.nullIfAllEqual("-")
?.joinToString(", "),
country,
)
.joinToString("\n")
.orNullIfBlank()
private val IdentityView.identityName: String?
get() = listOfNotNull(
title
?.lowercase()
?.capitalize(),
firstName,
middleName,
lastName,
)
.joinToString(" ")
.orNullIfBlank()